1use super::{TaskGroup, TaskNode, Tasks};
2use crate::{Error, Result};
3use serde::Serialize;
4use std::collections::{BTreeMap, HashMap};
5
6#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
8pub struct TaskPath {
9 segments: Vec<String>,
10}
11
12impl TaskPath {
13 pub fn parse(raw: &str) -> Result<Self> {
15 if raw.trim().is_empty() {
16 return Err(Error::configuration("Task name cannot be empty"));
17 }
18
19 let normalized = raw.replace(':', ".");
20 let segments: Vec<String> = normalized
21 .split('.')
22 .filter(|s| !s.is_empty())
23 .map(|s| s.trim().to_string())
24 .collect();
25
26 if segments.is_empty() {
27 return Err(Error::configuration("Task name cannot be empty"));
28 }
29
30 for segment in &segments {
31 validate_segment(segment)?;
32 }
33
34 Ok(Self { segments })
35 }
36
37 pub fn join(&self, segment: &str) -> Result<Self> {
39 validate_segment(segment)?;
40 let mut next = self.segments.clone();
41 next.push(segment.to_string());
42 Ok(Self { segments: next })
43 }
44
45 pub fn canonical(&self) -> String {
47 self.segments.join(".")
48 }
49
50 pub fn segments(&self) -> &[String] {
52 &self.segments
53 }
54}
55
56fn validate_segment(segment: &str) -> Result<()> {
57 if segment.is_empty() {
58 return Err(Error::configuration("Task name segment cannot be empty"));
59 }
60
61 if segment.contains('.') || segment.contains(':') {
62 return Err(Error::configuration(format!(
63 "Task name segment '{segment}' may not contain '.' or ':'"
64 )));
65 }
66
67 Ok(())
68}
69
70#[derive(Debug, Clone, Serialize)]
71pub struct IndexedTask {
72 pub name: String,
74 pub original_name: String,
76 pub node: TaskNode,
77 pub is_group: bool,
78 pub source_file: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize)]
84pub struct WorkspaceTask {
85 pub project: String,
87 pub task: String,
89 pub task_ref: String,
91 pub description: Option<String>,
93 pub is_group: bool,
95}
96
97#[derive(Debug, Clone, Default)]
99pub struct TaskIndex {
100 entries: BTreeMap<String, IndexedTask>,
101}
102
103impl TaskIndex {
104 pub fn build(tasks: &HashMap<String, TaskNode>) -> Result<Self> {
111 let mut entries = BTreeMap::new();
112
113 for (name, node) in tasks {
114 let (display_name, original_name) = if let Some(stripped) = name.strip_prefix('_') {
116 (stripped.to_string(), name.clone())
117 } else {
118 (name.clone(), name.clone())
119 };
120
121 let source_file = extract_source_file(node);
123
124 let path = TaskPath::parse(&display_name)?;
125 let _ = canonicalize_node(node, &path, &mut entries, original_name, source_file)?;
126 }
127
128 Ok(Self { entries })
129 }
130
131 pub fn resolve(&self, raw: &str) -> Result<&IndexedTask> {
133 let path = TaskPath::parse(raw)?;
134 let canonical = path.canonical();
135 self.entries.get(&canonical).ok_or_else(|| {
136 let available: Vec<&str> = self.entries.keys().map(String::as_str).collect();
137
138 let suggestions: Vec<&str> = available
140 .iter()
141 .filter(|t| is_similar(&canonical, t))
142 .copied()
143 .collect();
144
145 let mut msg = format!("Task '{}' not found.", canonical);
146
147 if !suggestions.is_empty() {
148 msg.push_str("\n\nDid you mean one of these?\n");
149 for s in &suggestions {
150 msg.push_str(&format!(" - {s}\n"));
151 }
152 }
153
154 if !available.is_empty() {
155 msg.push_str("\nAvailable tasks:\n");
156 for t in &available {
157 msg.push_str(&format!(" - {t}\n"));
158 }
159 }
160
161 Error::configuration(msg)
162 })
163 }
164
165 pub fn list(&self) -> Vec<&IndexedTask> {
167 self.entries.values().collect()
168 }
169
170 pub fn to_tasks(&self) -> Tasks {
172 let tasks = self
173 .entries
174 .iter()
175 .map(|(name, entry)| (name.clone(), entry.node.clone()))
176 .collect();
177
178 Tasks { tasks }
179 }
180}
181
182fn extract_source_file(node: &TaskNode) -> Option<String> {
184 match node {
185 TaskNode::Task(task) => task.source.as_ref().map(|s| s.file.clone()),
186 TaskNode::Group(group) => {
187 group.children.values().next().and_then(extract_source_file)
189 }
190 TaskNode::Sequence(steps) => {
191 steps.first().and_then(extract_source_file)
193 }
194 }
195}
196
197fn canonicalize_node(
198 node: &TaskNode,
199 path: &TaskPath,
200 entries: &mut BTreeMap<String, IndexedTask>,
201 original_name: String,
202 source_file: Option<String>,
203) -> Result<TaskNode> {
204 match node {
205 TaskNode::Task(task) => {
206 let canon_task = if task.project_root.is_some() && task.task_ref.is_none() {
209 task.as_ref().clone()
210 } else {
211 let mut clone = task.as_ref().clone();
212 let mut canonical_deps = Vec::new();
213 for dep in &task.depends_on {
214 let canonical_name = canonicalize_dep(dep.task_name())?;
215 canonical_deps.push(super::TaskDependency::from_name(canonical_name));
216 }
217 clone.depends_on = canonical_deps;
218 clone
219 };
220
221 let name = path.canonical();
222 entries.insert(
223 name.clone(),
224 IndexedTask {
225 name,
226 original_name,
227 node: TaskNode::Task(Box::new(canon_task.clone())),
228 is_group: false,
229 source_file,
230 },
231 );
232 Ok(TaskNode::Task(Box::new(canon_task)))
233 }
234 TaskNode::Group(group) => {
235 let mut canon_children = HashMap::new();
236 for (child_name, child_node) in &group.children {
237 let child_path = path.join(child_name)?;
238 let child_source = extract_source_file(child_node);
240 let child_original = child_name.clone();
241 let canon_child = canonicalize_node(
242 child_node,
243 &child_path,
244 entries,
245 child_original,
246 child_source,
247 )?;
248 canon_children.insert(child_name.clone(), canon_child);
249 }
250
251 let name = path.canonical();
252 let node = TaskNode::Group(TaskGroup {
253 type_: "group".to_string(),
254 children: canon_children,
255 depends_on: group.depends_on.clone(),
256 max_concurrency: group.max_concurrency,
257 description: group.description.clone(),
258 });
259 entries.insert(
260 name.clone(),
261 IndexedTask {
262 name,
263 original_name,
264 node: node.clone(),
265 is_group: true,
266 source_file,
267 },
268 );
269
270 Ok(node)
271 }
272 TaskNode::Sequence(steps) => {
273 let mut canon_children = Vec::with_capacity(steps.len());
275 for child in steps {
276 let child_source = extract_source_file(child);
280 let canon_child =
281 canonicalize_node(child, path, entries, original_name.clone(), child_source)?;
282 canon_children.push(canon_child);
283 }
284
285 let name = path.canonical();
286 let node = TaskNode::Sequence(canon_children);
287 entries.insert(
288 name.clone(),
289 IndexedTask {
290 name,
291 original_name,
292 node: node.clone(),
293 is_group: true,
294 source_file,
295 },
296 );
297
298 Ok(node)
299 }
300 }
301}
302
303fn canonicalize_dep(dep: &str) -> Result<String> {
304 Ok(TaskPath::parse(dep)?.canonical())
308}
309
310fn is_similar(input: &str, candidate: &str) -> bool {
312 if candidate.starts_with(input) || input.starts_with(candidate) {
314 return true;
315 }
316
317 let input_lower = input.to_lowercase();
319 let candidate_lower = candidate.to_lowercase();
320
321 let common_prefix = input_lower
323 .chars()
324 .zip(candidate_lower.chars())
325 .take_while(|(a, b)| a == b)
326 .count();
327 if common_prefix >= 3 {
328 return true;
329 }
330
331 if input.len() <= 10 && candidate.len() <= 10 {
333 let distance = levenshtein(&input_lower, &candidate_lower);
334 return distance <= 2;
335 }
336
337 false
338}
339
340fn levenshtein(a: &str, b: &str) -> usize {
342 let a_chars: Vec<char> = a.chars().collect();
343 let b_chars: Vec<char> = b.chars().collect();
344 let m = a_chars.len();
345 let n = b_chars.len();
346
347 if m == 0 {
348 return n;
349 }
350 if n == 0 {
351 return m;
352 }
353
354 let mut prev: Vec<usize> = (0..=n).collect();
355 let mut curr = vec![0; n + 1];
356
357 for i in 1..=m {
358 curr[0] = i;
359 for j in 1..=n {
360 let cost = if a_chars[i - 1] == b_chars[j - 1] {
361 0
362 } else {
363 1
364 };
365 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
366 }
367 std::mem::swap(&mut prev, &mut curr);
368 }
369
370 prev[n]
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::tasks::{Task, TaskDependency};
377
378 #[test]
383 fn test_task_path_parse_simple() {
384 let path = TaskPath::parse("build").unwrap();
385 assert_eq!(path.canonical(), "build");
386 assert_eq!(path.segments(), &["build"]);
387 }
388
389 #[test]
390 fn test_task_path_parse_dotted() {
391 let path = TaskPath::parse("test.unit").unwrap();
392 assert_eq!(path.canonical(), "test.unit");
393 assert_eq!(path.segments(), &["test", "unit"]);
394 }
395
396 #[test]
397 fn test_task_path_parse_colon_separated() {
398 let path = TaskPath::parse("test:integration").unwrap();
399 assert_eq!(path.canonical(), "test.integration");
400 assert_eq!(path.segments(), &["test", "integration"]);
401 }
402
403 #[test]
404 fn test_task_path_parse_mixed_separators() {
405 let path = TaskPath::parse("build:release.optimized").unwrap();
406 assert_eq!(path.canonical(), "build.release.optimized");
407 }
408
409 #[test]
410 fn test_task_path_parse_empty_error() {
411 assert!(TaskPath::parse("").is_err());
412 assert!(TaskPath::parse(" ").is_err());
413 }
414
415 #[test]
416 fn test_task_path_parse_only_separators_error() {
417 assert!(TaskPath::parse("...").is_err());
418 assert!(TaskPath::parse(":::").is_err());
419 }
420
421 #[test]
422 fn test_task_path_join() {
423 let path = TaskPath::parse("build").unwrap();
424 let joined = path.join("release").unwrap();
425 assert_eq!(joined.canonical(), "build.release");
426 }
427
428 #[test]
429 fn test_task_path_join_invalid_segment() {
430 let path = TaskPath::parse("build").unwrap();
431 assert!(path.join("").is_err());
432 assert!(path.join("foo.bar").is_err());
433 assert!(path.join("foo:bar").is_err());
434 }
435
436 #[test]
437 fn test_task_path_equality() {
438 let path1 = TaskPath::parse("test.unit").unwrap();
439 let path2 = TaskPath::parse("test:unit").unwrap();
440 assert_eq!(path1, path2);
441 }
442
443 #[test]
448 fn test_validate_segment_valid() {
449 assert!(validate_segment("build").is_ok());
450 assert!(validate_segment("test-unit").is_ok());
451 assert!(validate_segment("my_task").is_ok());
452 assert!(validate_segment("task123").is_ok());
453 }
454
455 #[test]
456 fn test_validate_segment_empty() {
457 assert!(validate_segment("").is_err());
458 }
459
460 #[test]
461 fn test_validate_segment_with_dot() {
462 assert!(validate_segment("foo.bar").is_err());
463 }
464
465 #[test]
466 fn test_validate_segment_with_colon() {
467 assert!(validate_segment("foo:bar").is_err());
468 }
469
470 #[test]
475 fn test_task_index_build_single_task() {
476 let mut tasks = HashMap::new();
477 tasks.insert(
478 "build".to_string(),
479 TaskNode::Task(Box::new(Task {
480 command: "cargo build".to_string(),
481 ..Default::default()
482 })),
483 );
484
485 let index = TaskIndex::build(&tasks).unwrap();
486 assert_eq!(index.list().len(), 1);
487
488 let resolved = index.resolve("build").unwrap();
489 assert_eq!(resolved.name, "build");
490 assert!(!resolved.is_group);
491 }
492
493 #[test]
494 fn test_task_index_build_underscore_prefix() {
495 let mut tasks = HashMap::new();
496 tasks.insert(
497 "_private".to_string(),
498 TaskNode::Task(Box::new(Task {
499 command: "echo private".to_string(),
500 ..Default::default()
501 })),
502 );
503
504 let index = TaskIndex::build(&tasks).unwrap();
505
506 let resolved = index.resolve("private").unwrap();
508 assert_eq!(resolved.name, "private");
509 assert_eq!(resolved.original_name, "_private");
510 }
511
512 #[test]
513 fn test_task_index_build_nested_tasks() {
514 let mut tasks = HashMap::new();
515 tasks.insert(
516 "test.unit".to_string(),
517 TaskNode::Task(Box::new(Task {
518 command: "cargo test".to_string(),
519 ..Default::default()
520 })),
521 );
522 tasks.insert(
523 "test.integration".to_string(),
524 TaskNode::Task(Box::new(Task {
525 command: "cargo test --test integration".to_string(),
526 ..Default::default()
527 })),
528 );
529
530 let index = TaskIndex::build(&tasks).unwrap();
531 assert_eq!(index.list().len(), 2);
532
533 assert!(index.resolve("test.unit").is_ok());
535 assert!(index.resolve("test:integration").is_ok());
537 }
538
539 #[test]
540 fn test_task_index_resolve_not_found() {
541 let tasks = HashMap::new();
542 let index = TaskIndex::build(&tasks).unwrap();
543
544 let result = index.resolve("nonexistent");
545 assert!(result.is_err());
546
547 let err = result.unwrap_err().to_string();
548 assert!(err.contains("not found"));
549 }
550
551 #[test]
552 fn test_task_index_resolve_with_suggestions() {
553 let mut tasks = HashMap::new();
554 tasks.insert(
555 "build".to_string(),
556 TaskNode::Task(Box::new(Task {
557 command: "cargo build".to_string(),
558 ..Default::default()
559 })),
560 );
561
562 let index = TaskIndex::build(&tasks).unwrap();
563
564 let result = index.resolve("buld");
566 assert!(result.is_err());
567
568 let err = result.unwrap_err().to_string();
569 assert!(err.contains("Did you mean"));
570 assert!(err.contains("build"));
571 }
572
573 #[test]
574 fn test_task_index_list_deterministic_order() {
575 let mut tasks = HashMap::new();
576 tasks.insert(
577 "zebra".to_string(),
578 TaskNode::Task(Box::new(Task {
579 command: "echo z".to_string(),
580 ..Default::default()
581 })),
582 );
583 tasks.insert(
584 "apple".to_string(),
585 TaskNode::Task(Box::new(Task {
586 command: "echo a".to_string(),
587 ..Default::default()
588 })),
589 );
590 tasks.insert(
591 "mango".to_string(),
592 TaskNode::Task(Box::new(Task {
593 command: "echo m".to_string(),
594 ..Default::default()
595 })),
596 );
597
598 let index = TaskIndex::build(&tasks).unwrap();
599 let list = index.list();
600
601 assert_eq!(list[0].name, "apple");
603 assert_eq!(list[1].name, "mango");
604 assert_eq!(list[2].name, "zebra");
605 }
606
607 #[test]
608 fn test_task_index_to_tasks() {
609 let mut tasks = HashMap::new();
610 tasks.insert(
611 "build".to_string(),
612 TaskNode::Task(Box::new(Task {
613 command: "cargo build".to_string(),
614 ..Default::default()
615 })),
616 );
617
618 let index = TaskIndex::build(&tasks).unwrap();
619 let converted = index.to_tasks();
620
621 assert!(converted.tasks.contains_key("build"));
622 }
623
624 #[test]
629 fn test_is_similar_prefix_match() {
630 assert!(is_similar("build", "build-release"));
631 assert!(is_similar("test", "testing"));
632 }
633
634 #[test]
635 fn test_is_similar_common_prefix() {
636 assert!(is_similar("build", "builder"));
637 assert!(is_similar("testing", "tester"));
638 }
639
640 #[test]
641 fn test_is_similar_edit_distance() {
642 assert!(is_similar("build", "buld")); assert!(is_similar("test", "tset")); assert!(is_similar("task", "taks")); }
646
647 #[test]
648 fn test_is_similar_not_similar() {
649 assert!(!is_similar("build", "zebra"));
650 assert!(!is_similar("a", "xyz"));
651 }
652
653 #[test]
654 fn test_levenshtein_identical() {
655 assert_eq!(levenshtein("hello", "hello"), 0);
656 }
657
658 #[test]
659 fn test_levenshtein_empty() {
660 assert_eq!(levenshtein("", "hello"), 5);
661 assert_eq!(levenshtein("hello", ""), 5);
662 assert_eq!(levenshtein("", ""), 0);
663 }
664
665 #[test]
666 fn test_levenshtein_single_edit() {
667 assert_eq!(levenshtein("cat", "car"), 1); assert_eq!(levenshtein("cat", "cats"), 1); assert_eq!(levenshtein("cats", "cat"), 1); }
671
672 #[test]
673 fn test_levenshtein_multiple_edits() {
674 assert_eq!(levenshtein("kitten", "sitting"), 3);
675 }
676
677 #[test]
682 fn test_indexed_task_debug() {
683 let task = IndexedTask {
684 name: "build".to_string(),
685 original_name: "build".to_string(),
686 node: TaskNode::Task(Box::default()),
687 is_group: false,
688 source_file: Some("env.cue".to_string()),
689 };
690
691 let debug = format!("{:?}", task);
692 assert!(debug.contains("build"));
693 assert!(debug.contains("env.cue"));
694 }
695
696 #[test]
697 fn test_indexed_task_clone() {
698 let task = IndexedTask {
699 name: "build".to_string(),
700 original_name: "_build".to_string(),
701 node: TaskNode::Task(Box::default()),
702 is_group: false,
703 source_file: None,
704 };
705
706 let cloned = task.clone();
707 assert_eq!(cloned.name, task.name);
708 assert_eq!(cloned.original_name, task.original_name);
709 }
710
711 #[test]
716 fn test_workspace_task_debug() {
717 let task = WorkspaceTask {
718 project: "my-project".to_string(),
719 task: "build".to_string(),
720 task_ref: "#my-project:build".to_string(),
721 description: Some("Build the project".to_string()),
722 is_group: false,
723 };
724
725 let debug = format!("{:?}", task);
726 assert!(debug.contains("my-project"));
727 assert!(debug.contains("build"));
728 }
729
730 #[test]
731 fn test_workspace_task_serialize() {
732 let task = WorkspaceTask {
733 project: "api".to_string(),
734 task: "test.unit".to_string(),
735 task_ref: "#api:test.unit".to_string(),
736 description: None,
737 is_group: false,
738 };
739
740 let json = serde_json::to_string(&task).unwrap();
741 assert!(json.contains("api"));
742 assert!(json.contains("test.unit"));
743 }
744
745 #[test]
750 fn test_task_path_clone() {
751 let path = TaskPath::parse("build.release").unwrap();
752 let cloned = path.clone();
753 assert_eq!(path, cloned);
754 }
755
756 #[test]
757 fn test_task_path_serialize() {
758 let path = TaskPath::parse("test.unit").unwrap();
759 let json = serde_json::to_string(&path).unwrap();
760 assert!(json.contains("test"));
761 assert!(json.contains("unit"));
762 }
763
764 #[test]
769 fn test_task_index_preserves_dependency_names_as_given() {
770 let mut tasks = HashMap::new();
775
776 tasks.insert(
778 "build".to_string(),
779 TaskNode::Task(Box::new(Task {
780 command: "cargo build".to_string(),
781 ..Default::default()
782 })),
783 );
784
785 let mut deploy_children = HashMap::new();
787 deploy_children.insert(
788 "preview".to_string(),
789 TaskNode::Task(Box::new(Task {
790 command: "deploy preview".to_string(),
791 depends_on: vec![TaskDependency::from_name("build")],
794 ..Default::default()
795 })),
796 );
797 tasks.insert(
798 "deploy".to_string(),
799 TaskNode::Group(TaskGroup {
800 type_: "group".to_string(),
801 children: deploy_children,
802 depends_on: vec![],
803 max_concurrency: None,
804 description: None,
805 }),
806 );
807
808 let index = TaskIndex::build(&tasks).unwrap();
809 let preview_task = index.resolve("deploy.preview").unwrap();
810
811 match &preview_task.node {
812 TaskNode::Task(task) => {
813 assert_eq!(task.depends_on.len(), 1);
814 assert_eq!(task.depends_on[0].task_name(), "build");
816 }
817 _ => panic!("Expected Task"),
818 }
819 }
820
821 #[test]
822 fn test_group_child_depends_on_sibling_qualified() {
823 let mut tasks = HashMap::new();
827
828 let mut deploy_children = HashMap::new();
829 deploy_children.insert(
830 "upload".to_string(),
831 TaskNode::Task(Box::new(Task {
832 command: "upload".to_string(),
833 ..Default::default()
834 })),
835 );
836 deploy_children.insert(
837 "activate".to_string(),
838 TaskNode::Task(Box::new(Task {
839 command: "activate".to_string(),
840 depends_on: vec![TaskDependency::from_name("deploy.upload")],
842 ..Default::default()
843 })),
844 );
845 tasks.insert(
846 "deploy".to_string(),
847 TaskNode::Group(TaskGroup {
848 type_: "group".to_string(),
849 children: deploy_children,
850 depends_on: vec![],
851 max_concurrency: None,
852 description: None,
853 }),
854 );
855
856 let index = TaskIndex::build(&tasks).unwrap();
857 let activate_task = index.resolve("deploy.activate").unwrap();
858
859 match &activate_task.node {
860 TaskNode::Task(task) => {
861 assert_eq!(task.depends_on.len(), 1);
862 assert_eq!(task.depends_on[0].task_name(), "deploy.upload");
863 }
864 _ => panic!("Expected Task"),
865 }
866 }
867
868 #[test]
869 fn test_dotted_dependency_treated_as_absolute() {
870 let mut tasks = HashMap::new();
873
874 let mut other_children = HashMap::new();
876 other_children.insert(
877 "task".to_string(),
878 TaskNode::Task(Box::new(Task {
879 command: "other task".to_string(),
880 ..Default::default()
881 })),
882 );
883 tasks.insert(
884 "other".to_string(),
885 TaskNode::Group(TaskGroup {
886 type_: "group".to_string(),
887 children: other_children,
888 depends_on: vec![],
889 max_concurrency: None,
890 description: None,
891 }),
892 );
893
894 let mut deploy_children = HashMap::new();
896 deploy_children.insert(
897 "preview".to_string(),
898 TaskNode::Task(Box::new(Task {
899 command: "deploy preview".to_string(),
900 depends_on: vec![TaskDependency::from_name("other.task")],
901 ..Default::default()
902 })),
903 );
904 tasks.insert(
905 "deploy".to_string(),
906 TaskNode::Group(TaskGroup {
907 type_: "group".to_string(),
908 children: deploy_children,
909 depends_on: vec![],
910 max_concurrency: None,
911 description: None,
912 }),
913 );
914
915 let index = TaskIndex::build(&tasks).unwrap();
916 let preview_task = index.resolve("deploy.preview").unwrap();
917
918 match &preview_task.node {
919 TaskNode::Task(task) => {
920 assert_eq!(task.depends_on.len(), 1);
921 assert_eq!(task.depends_on[0].task_name(), "other.task");
922 }
923 _ => panic!("Expected Task"),
924 }
925 }
926
927 #[test]
928 fn test_cross_group_dependency() {
929 let mut tasks = HashMap::new();
932
933 let mut build_children = HashMap::new();
935 build_children.insert(
936 "compile".to_string(),
937 TaskNode::Task(Box::new(Task {
938 command: "compile".to_string(),
939 ..Default::default()
940 })),
941 );
942 tasks.insert(
943 "build".to_string(),
944 TaskNode::Group(TaskGroup {
945 type_: "group".to_string(),
946 children: build_children,
947 depends_on: vec![],
948 max_concurrency: None,
949 description: None,
950 }),
951 );
952
953 let mut deploy_children = HashMap::new();
955 deploy_children.insert(
956 "run".to_string(),
957 TaskNode::Task(Box::new(Task {
958 command: "deploy run".to_string(),
959 depends_on: vec![TaskDependency::from_name("build.compile")],
960 ..Default::default()
961 })),
962 );
963 tasks.insert(
964 "deploy".to_string(),
965 TaskNode::Group(TaskGroup {
966 type_: "group".to_string(),
967 children: deploy_children,
968 depends_on: vec![],
969 max_concurrency: None,
970 description: None,
971 }),
972 );
973
974 let index = TaskIndex::build(&tasks).unwrap();
975 let run_task = index.resolve("deploy.run").unwrap();
976
977 match &run_task.node {
978 TaskNode::Task(task) => {
979 assert_eq!(task.depends_on.len(), 1);
980 assert_eq!(task.depends_on[0].task_name(), "build.compile");
981 }
982 _ => panic!("Expected Task"),
983 }
984 }
985
986 #[test]
987 fn test_task_index_preserves_invalid_references() {
988 let mut tasks = HashMap::new();
993
994 let mut deploy_children = HashMap::new();
995 deploy_children.insert(
996 "preview".to_string(),
997 TaskNode::Task(Box::new(Task {
998 command: "deploy preview".to_string(),
999 depends_on: vec![TaskDependency::from_name("nonexistent")],
1001 ..Default::default()
1002 })),
1003 );
1004 tasks.insert(
1005 "deploy".to_string(),
1006 TaskNode::Group(TaskGroup {
1007 type_: "group".to_string(),
1008 children: deploy_children,
1009 depends_on: vec![],
1010 max_concurrency: None,
1011 description: None,
1012 }),
1013 );
1014
1015 let index = TaskIndex::build(&tasks).unwrap();
1016 let preview_task = index.resolve("deploy.preview").unwrap();
1017
1018 match &preview_task.node {
1019 TaskNode::Task(task) => {
1020 assert_eq!(task.depends_on[0].task_name(), "nonexistent");
1022 }
1023 _ => panic!("Expected Task"),
1024 }
1025 }
1026}