Skip to main content

cuenv_task_discovery/
lib.rs

1//! Task discovery across monorepo workspaces
2//!
3//! This crate provides functionality to discover tasks across a monorepo,
4//! supporting TaskRef resolution and TaskMatcher-based task discovery.
5
6use 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/// A discovered project in the workspace
15#[derive(Debug, Clone)]
16pub struct DiscoveredProject {
17    /// Path to the env.cue file
18    pub env_cue_path: PathBuf,
19    /// Path to the project root (directory containing env.cue)
20    pub project_root: PathBuf,
21    /// The parsed manifest
22    pub manifest: Project,
23}
24
25/// Result of matching a task
26#[derive(Debug, Clone)]
27pub struct MatchedTask {
28    /// Path to the project containing this task
29    pub project_root: PathBuf,
30    /// Name of the task
31    pub task_name: String,
32    /// The task definition
33    pub task: Task,
34    /// Project name (from env.cue name field)
35    pub project_name: Option<String>,
36}
37
38/// Discovers tasks across a monorepo workspace
39pub struct TaskDiscovery {
40    /// Cached project index: name -> project
41    name_index: HashMap<String, DiscoveredProject>,
42    /// All discovered projects
43    projects: Vec<DiscoveredProject>,
44}
45
46impl TaskDiscovery {
47    /// Create a new TaskDiscovery
48    ///
49    /// The workspace root parameter is kept for API compatibility but unused.
50    /// Projects are added explicitly via `add_project()`.
51    pub fn new(_workspace_root: PathBuf) -> Self {
52        Self {
53            name_index: HashMap::new(),
54            projects: Vec::new(),
55        }
56    }
57
58    /// Add a pre-loaded project to the discovery
59    ///
60    /// This is useful when you already have a Project manifest loaded.
61    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        // Build name index
70        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    /// Resolve a TaskRef to its actual task definition
78    ///
79    /// Returns the project root and the task if found
80    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        // We only support single tasks, not task groups, for TaskRef
96        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    /// Find all tasks matching a TaskMatcher
110    ///
111    /// Returns an error if any regex pattern in the matcher is invalid.
112    pub fn match_tasks(&self, matcher: &TaskMatcher) -> Result<Vec<MatchedTask>, DiscoveryError> {
113        // Pre-compile arg matchers to catch regex errors early and avoid recompilation
114        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            // Use the canonical TaskIndex to include tasks nested in parallel groups.
123            let index = TaskIndex::build(&project.manifest.tasks).map_err(|e| {
124                DiscoveryError::TaskIndexError(project.env_cue_path.clone(), e.to_string())
125            })?;
126
127            // Check each addressable single task in the project
128            for entry in index.list() {
129                let Some(task) = entry.node.as_task() else {
130                    continue;
131                };
132
133                // Match by labels
134                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                // Match by command
144                if let Some(required_command) = &matcher.command
145                    && &task.command != required_command
146                {
147                    continue;
148                }
149
150                // Match by args using pre-compiled matchers
151                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    /// Get all discovered projects
171    pub fn projects(&self) -> &[DiscoveredProject] {
172        &self.projects
173    }
174
175    /// Get a project by name
176    pub fn get_project(&self, name: &str) -> Option<&DiscoveredProject> {
177        self.name_index.get(name)
178    }
179}
180
181/// Compiled version of ArgMatcher for efficient matching
182#[derive(Debug)]
183struct CompiledArgMatcher {
184    contains: Option<String>,
185    regex: Option<Regex>,
186}
187
188impl CompiledArgMatcher {
189    /// Compile an ArgMatcher, validating regex patterns
190    fn compile(matcher: &ArgMatcher) -> Result<Self, DiscoveryError> {
191        let regex = match &matcher.matches {
192            Some(pattern) => {
193                // Use regex with size limits to prevent ReDoS
194                let regex = regex::RegexBuilder::new(pattern)
195                    .size_limit(1024 * 1024) // 1MB compiled size limit
196                    .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    /// Check if any argument matches this matcher
209    fn matches(&self, args: &[String]) -> bool {
210        // If both are None, this matcher matches nothing (conservative behavior)
211        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
231/// Pre-compile all arg matchers, returning errors for invalid patterns
232fn compile_arg_matchers(
233    matchers: &[ArgMatcher],
234) -> Result<Vec<CompiledArgMatcher>, DiscoveryError> {
235    matchers.iter().map(CompiledArgMatcher::compile).collect()
236}
237
238/// Check if task args match all arg matchers (using pre-compiled matchers)
239fn matches_args_compiled(args: &[String], matchers: &[CompiledArgMatcher]) -> bool {
240    matchers.iter().all(|matcher| matcher.matches(args))
241}
242
243/// Errors that can occur during task discovery
244#[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    /// Helper to compile and match for tests
305    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()), // Unclosed bracket
345        }];
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        // Empty matcher should not match anything
360        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        // Project with empty name should still be added to projects list
417        // but not to the name index
418        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()); // Empty names not indexed
423    }
424
425    #[test]
426    fn test_task_discovery_add_project_with_whitespace_name() {
427        let mut discovery = TaskDiscovery::new(PathBuf::from("/tmp"));
428
429        // Project with whitespace-only name should not be indexed
430        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        // Match tasks with both "ci" and "test" labels
611        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        // When both contains and matches are provided, either can match
643        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        // Check contains matches
650        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        // All matchers must match
666        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        // If one doesn't match, result is false
679        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        // Empty matchers list means all tasks match (vacuous truth)
709        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}