1use super::{Task, 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 = canonicalize_task(task.as_ref(), path)?;
207 let name = path.canonical();
208 entries.insert(
209 name.clone(),
210 IndexedTask {
211 name,
212 original_name,
213 node: TaskNode::Task(Box::new(canon_task.clone())),
214 is_group: false,
215 source_file,
216 },
217 );
218 Ok(TaskNode::Task(Box::new(canon_task)))
219 }
220 TaskNode::Group(group) => {
221 let mut canon_children = HashMap::new();
222 for (child_name, child_node) in &group.children {
223 let child_path = path.join(child_name)?;
224 let child_source = extract_source_file(child_node);
226 let child_original = child_name.clone();
227 let canon_child = canonicalize_node(
228 child_node,
229 &child_path,
230 entries,
231 child_original,
232 child_source,
233 )?;
234 canon_children.insert(child_name.clone(), canon_child);
235 }
236
237 let name = path.canonical();
238 let node = TaskNode::Group(TaskGroup {
239 type_: "group".to_string(),
240 children: canon_children,
241 depends_on: group.depends_on.clone(),
242 max_concurrency: group.max_concurrency,
243 description: group.description.clone(),
244 });
245 entries.insert(
246 name.clone(),
247 IndexedTask {
248 name,
249 original_name,
250 node: node.clone(),
251 is_group: true,
252 source_file,
253 },
254 );
255
256 Ok(node)
257 }
258 TaskNode::Sequence(steps) => {
259 let mut canon_children = Vec::with_capacity(steps.len());
261 for child in steps {
262 let child_source = extract_source_file(child);
266 let canon_child =
267 canonicalize_node(child, path, entries, original_name.clone(), child_source)?;
268 canon_children.push(canon_child);
269 }
270
271 let name = path.canonical();
272 let node = TaskNode::Sequence(canon_children);
273 entries.insert(
274 name.clone(),
275 IndexedTask {
276 name,
277 original_name,
278 node: node.clone(),
279 is_group: true,
280 source_file,
281 },
282 );
283
284 Ok(node)
285 }
286 }
287}
288
289fn canonicalize_task(task: &Task, path: &TaskPath) -> Result<Task> {
290 if task.project_root.is_some() && task.task_ref.is_none() {
294 return Ok(task.clone());
295 }
296
297 let mut clone = task.clone();
298 let mut canonical_deps = Vec::new();
299 for dep in &task.depends_on {
300 let canonical_name = canonicalize_dep(dep.task_name(), path)?;
301 canonical_deps.push(super::TaskDependency::from_name(canonical_name));
302 }
303 clone.depends_on = canonical_deps;
304 Ok(clone)
305}
306
307fn canonicalize_dep(dep: &str, current_path: &TaskPath) -> Result<String> {
308 if dep.contains('.') || dep.contains(':') {
309 return Ok(TaskPath::parse(dep)?.canonical());
310 }
311
312 let mut segments: Vec<String> = current_path.segments().to_vec();
313 segments.pop(); segments.push(dep.to_string());
315
316 let rel = TaskPath { segments };
317 Ok(rel.canonical())
318}
319
320fn is_similar(input: &str, candidate: &str) -> bool {
322 if candidate.starts_with(input) || input.starts_with(candidate) {
324 return true;
325 }
326
327 let input_lower = input.to_lowercase();
329 let candidate_lower = candidate.to_lowercase();
330
331 let common_prefix = input_lower
333 .chars()
334 .zip(candidate_lower.chars())
335 .take_while(|(a, b)| a == b)
336 .count();
337 if common_prefix >= 3 {
338 return true;
339 }
340
341 if input.len() <= 10 && candidate.len() <= 10 {
343 let distance = levenshtein(&input_lower, &candidate_lower);
344 return distance <= 2;
345 }
346
347 false
348}
349
350fn levenshtein(a: &str, b: &str) -> usize {
352 let a_chars: Vec<char> = a.chars().collect();
353 let b_chars: Vec<char> = b.chars().collect();
354 let m = a_chars.len();
355 let n = b_chars.len();
356
357 if m == 0 {
358 return n;
359 }
360 if n == 0 {
361 return m;
362 }
363
364 let mut prev: Vec<usize> = (0..=n).collect();
365 let mut curr = vec![0; n + 1];
366
367 for i in 1..=m {
368 curr[0] = i;
369 for j in 1..=n {
370 let cost = if a_chars[i - 1] == b_chars[j - 1] {
371 0
372 } else {
373 1
374 };
375 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
376 }
377 std::mem::swap(&mut prev, &mut curr);
378 }
379
380 prev[n]
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
392 fn test_task_path_parse_simple() {
393 let path = TaskPath::parse("build").unwrap();
394 assert_eq!(path.canonical(), "build");
395 assert_eq!(path.segments(), &["build"]);
396 }
397
398 #[test]
399 fn test_task_path_parse_dotted() {
400 let path = TaskPath::parse("test.unit").unwrap();
401 assert_eq!(path.canonical(), "test.unit");
402 assert_eq!(path.segments(), &["test", "unit"]);
403 }
404
405 #[test]
406 fn test_task_path_parse_colon_separated() {
407 let path = TaskPath::parse("test:integration").unwrap();
408 assert_eq!(path.canonical(), "test.integration");
409 assert_eq!(path.segments(), &["test", "integration"]);
410 }
411
412 #[test]
413 fn test_task_path_parse_mixed_separators() {
414 let path = TaskPath::parse("build:release.optimized").unwrap();
415 assert_eq!(path.canonical(), "build.release.optimized");
416 }
417
418 #[test]
419 fn test_task_path_parse_empty_error() {
420 assert!(TaskPath::parse("").is_err());
421 assert!(TaskPath::parse(" ").is_err());
422 }
423
424 #[test]
425 fn test_task_path_parse_only_separators_error() {
426 assert!(TaskPath::parse("...").is_err());
427 assert!(TaskPath::parse(":::").is_err());
428 }
429
430 #[test]
431 fn test_task_path_join() {
432 let path = TaskPath::parse("build").unwrap();
433 let joined = path.join("release").unwrap();
434 assert_eq!(joined.canonical(), "build.release");
435 }
436
437 #[test]
438 fn test_task_path_join_invalid_segment() {
439 let path = TaskPath::parse("build").unwrap();
440 assert!(path.join("").is_err());
441 assert!(path.join("foo.bar").is_err());
442 assert!(path.join("foo:bar").is_err());
443 }
444
445 #[test]
446 fn test_task_path_equality() {
447 let path1 = TaskPath::parse("test.unit").unwrap();
448 let path2 = TaskPath::parse("test:unit").unwrap();
449 assert_eq!(path1, path2);
450 }
451
452 #[test]
457 fn test_validate_segment_valid() {
458 assert!(validate_segment("build").is_ok());
459 assert!(validate_segment("test-unit").is_ok());
460 assert!(validate_segment("my_task").is_ok());
461 assert!(validate_segment("task123").is_ok());
462 }
463
464 #[test]
465 fn test_validate_segment_empty() {
466 assert!(validate_segment("").is_err());
467 }
468
469 #[test]
470 fn test_validate_segment_with_dot() {
471 assert!(validate_segment("foo.bar").is_err());
472 }
473
474 #[test]
475 fn test_validate_segment_with_colon() {
476 assert!(validate_segment("foo:bar").is_err());
477 }
478
479 #[test]
484 fn test_task_index_build_single_task() {
485 let mut tasks = HashMap::new();
486 tasks.insert(
487 "build".to_string(),
488 TaskNode::Task(Box::new(Task {
489 command: "cargo build".to_string(),
490 ..Default::default()
491 })),
492 );
493
494 let index = TaskIndex::build(&tasks).unwrap();
495 assert_eq!(index.list().len(), 1);
496
497 let resolved = index.resolve("build").unwrap();
498 assert_eq!(resolved.name, "build");
499 assert!(!resolved.is_group);
500 }
501
502 #[test]
503 fn test_task_index_build_underscore_prefix() {
504 let mut tasks = HashMap::new();
505 tasks.insert(
506 "_private".to_string(),
507 TaskNode::Task(Box::new(Task {
508 command: "echo private".to_string(),
509 ..Default::default()
510 })),
511 );
512
513 let index = TaskIndex::build(&tasks).unwrap();
514
515 let resolved = index.resolve("private").unwrap();
517 assert_eq!(resolved.name, "private");
518 assert_eq!(resolved.original_name, "_private");
519 }
520
521 #[test]
522 fn test_task_index_build_nested_tasks() {
523 let mut tasks = HashMap::new();
524 tasks.insert(
525 "test.unit".to_string(),
526 TaskNode::Task(Box::new(Task {
527 command: "cargo test".to_string(),
528 ..Default::default()
529 })),
530 );
531 tasks.insert(
532 "test.integration".to_string(),
533 TaskNode::Task(Box::new(Task {
534 command: "cargo test --test integration".to_string(),
535 ..Default::default()
536 })),
537 );
538
539 let index = TaskIndex::build(&tasks).unwrap();
540 assert_eq!(index.list().len(), 2);
541
542 assert!(index.resolve("test.unit").is_ok());
544 assert!(index.resolve("test:integration").is_ok());
546 }
547
548 #[test]
549 fn test_task_index_resolve_not_found() {
550 let tasks = HashMap::new();
551 let index = TaskIndex::build(&tasks).unwrap();
552
553 let result = index.resolve("nonexistent");
554 assert!(result.is_err());
555
556 let err = result.unwrap_err().to_string();
557 assert!(err.contains("not found"));
558 }
559
560 #[test]
561 fn test_task_index_resolve_with_suggestions() {
562 let mut tasks = HashMap::new();
563 tasks.insert(
564 "build".to_string(),
565 TaskNode::Task(Box::new(Task {
566 command: "cargo build".to_string(),
567 ..Default::default()
568 })),
569 );
570
571 let index = TaskIndex::build(&tasks).unwrap();
572
573 let result = index.resolve("buld");
575 assert!(result.is_err());
576
577 let err = result.unwrap_err().to_string();
578 assert!(err.contains("Did you mean"));
579 assert!(err.contains("build"));
580 }
581
582 #[test]
583 fn test_task_index_list_deterministic_order() {
584 let mut tasks = HashMap::new();
585 tasks.insert(
586 "zebra".to_string(),
587 TaskNode::Task(Box::new(Task {
588 command: "echo z".to_string(),
589 ..Default::default()
590 })),
591 );
592 tasks.insert(
593 "apple".to_string(),
594 TaskNode::Task(Box::new(Task {
595 command: "echo a".to_string(),
596 ..Default::default()
597 })),
598 );
599 tasks.insert(
600 "mango".to_string(),
601 TaskNode::Task(Box::new(Task {
602 command: "echo m".to_string(),
603 ..Default::default()
604 })),
605 );
606
607 let index = TaskIndex::build(&tasks).unwrap();
608 let list = index.list();
609
610 assert_eq!(list[0].name, "apple");
612 assert_eq!(list[1].name, "mango");
613 assert_eq!(list[2].name, "zebra");
614 }
615
616 #[test]
617 fn test_task_index_to_tasks() {
618 let mut tasks = HashMap::new();
619 tasks.insert(
620 "build".to_string(),
621 TaskNode::Task(Box::new(Task {
622 command: "cargo build".to_string(),
623 ..Default::default()
624 })),
625 );
626
627 let index = TaskIndex::build(&tasks).unwrap();
628 let converted = index.to_tasks();
629
630 assert!(converted.tasks.contains_key("build"));
631 }
632
633 #[test]
638 fn test_is_similar_prefix_match() {
639 assert!(is_similar("build", "build-release"));
640 assert!(is_similar("test", "testing"));
641 }
642
643 #[test]
644 fn test_is_similar_common_prefix() {
645 assert!(is_similar("build", "builder"));
646 assert!(is_similar("testing", "tester"));
647 }
648
649 #[test]
650 fn test_is_similar_edit_distance() {
651 assert!(is_similar("build", "buld")); assert!(is_similar("test", "tset")); assert!(is_similar("task", "taks")); }
655
656 #[test]
657 fn test_is_similar_not_similar() {
658 assert!(!is_similar("build", "zebra"));
659 assert!(!is_similar("a", "xyz"));
660 }
661
662 #[test]
663 fn test_levenshtein_identical() {
664 assert_eq!(levenshtein("hello", "hello"), 0);
665 }
666
667 #[test]
668 fn test_levenshtein_empty() {
669 assert_eq!(levenshtein("", "hello"), 5);
670 assert_eq!(levenshtein("hello", ""), 5);
671 assert_eq!(levenshtein("", ""), 0);
672 }
673
674 #[test]
675 fn test_levenshtein_single_edit() {
676 assert_eq!(levenshtein("cat", "car"), 1); assert_eq!(levenshtein("cat", "cats"), 1); assert_eq!(levenshtein("cats", "cat"), 1); }
680
681 #[test]
682 fn test_levenshtein_multiple_edits() {
683 assert_eq!(levenshtein("kitten", "sitting"), 3);
684 }
685
686 #[test]
691 fn test_indexed_task_debug() {
692 let task = IndexedTask {
693 name: "build".to_string(),
694 original_name: "build".to_string(),
695 node: TaskNode::Task(Box::default()),
696 is_group: false,
697 source_file: Some("env.cue".to_string()),
698 };
699
700 let debug = format!("{:?}", task);
701 assert!(debug.contains("build"));
702 assert!(debug.contains("env.cue"));
703 }
704
705 #[test]
706 fn test_indexed_task_clone() {
707 let task = IndexedTask {
708 name: "build".to_string(),
709 original_name: "_build".to_string(),
710 node: TaskNode::Task(Box::default()),
711 is_group: false,
712 source_file: None,
713 };
714
715 let cloned = task.clone();
716 assert_eq!(cloned.name, task.name);
717 assert_eq!(cloned.original_name, task.original_name);
718 }
719
720 #[test]
725 fn test_workspace_task_debug() {
726 let task = WorkspaceTask {
727 project: "my-project".to_string(),
728 task: "build".to_string(),
729 task_ref: "#my-project:build".to_string(),
730 description: Some("Build the project".to_string()),
731 is_group: false,
732 };
733
734 let debug = format!("{:?}", task);
735 assert!(debug.contains("my-project"));
736 assert!(debug.contains("build"));
737 }
738
739 #[test]
740 fn test_workspace_task_serialize() {
741 let task = WorkspaceTask {
742 project: "api".to_string(),
743 task: "test.unit".to_string(),
744 task_ref: "#api:test.unit".to_string(),
745 description: None,
746 is_group: false,
747 };
748
749 let json = serde_json::to_string(&task).unwrap();
750 assert!(json.contains("api"));
751 assert!(json.contains("test.unit"));
752 }
753
754 #[test]
759 fn test_task_path_clone() {
760 let path = TaskPath::parse("build.release").unwrap();
761 let cloned = path.clone();
762 assert_eq!(path, cloned);
763 }
764
765 #[test]
766 fn test_task_path_serialize() {
767 let path = TaskPath::parse("test.unit").unwrap();
768 let json = serde_json::to_string(&path).unwrap();
769 assert!(json.contains("test"));
770 assert!(json.contains("unit"));
771 }
772}