cuenv_core/tasks/
index.rs

1use super::{Task, TaskGroup, TaskNode, Tasks};
2use crate::{Error, Result};
3use serde::Serialize;
4use std::collections::{BTreeMap, HashMap};
5
6/// Parsed task path that normalizes dotted/colon-separated identifiers
7#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
8pub struct TaskPath {
9    segments: Vec<String>,
10}
11
12impl TaskPath {
13    /// Parse a raw task path that may use '.' or ':' separators
14    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    /// Create a new path with an additional segment appended
38    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    /// Convert to canonical dotted representation
46    pub fn canonical(&self) -> String {
47        self.segments.join(".")
48    }
49
50    /// Return the underlying path segments
51    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    /// Display name (with _ prefix stripped if present)
73    pub name: String,
74    /// Original name from CUE (may have _ prefix)
75    pub original_name: String,
76    pub node: TaskNode,
77    pub is_group: bool,
78    /// Source file where this task was defined (relative to cue.mod root)
79    pub source_file: Option<String>,
80}
81
82/// Task reference for workspace-wide task listing (used by IDE completions)
83#[derive(Debug, Clone, Serialize)]
84pub struct WorkspaceTask {
85    /// Project name from env.cue `name` field
86    pub project: String,
87    /// Task name within the project (canonical dotted path)
88    pub task: String,
89    /// Full task reference string in format "#project:task"
90    pub task_ref: String,
91    /// Task description if available
92    pub description: Option<String>,
93    /// Whether this is a task group
94    pub is_group: bool,
95}
96
97/// Flattened index of all addressable tasks with canonical names
98#[derive(Debug, Clone, Default)]
99pub struct TaskIndex {
100    entries: BTreeMap<String, IndexedTask>,
101}
102
103impl TaskIndex {
104    /// Build a canonical index from the hierarchical task map
105    ///
106    /// Handles:
107    /// - Stripping `_` prefix from task names (CUE hidden fields for local-only tasks)
108    /// - Extracting source file from task metadata
109    /// - Canonicalizing nested task paths
110    pub fn build(tasks: &HashMap<String, TaskNode>) -> Result<Self> {
111        let mut entries = BTreeMap::new();
112
113        for (name, node) in tasks {
114            // Strip _ prefix for display/execution name
115            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            // Extract source file from task node
122            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    /// Resolve a raw task name (dot or colon separated) to an indexed task
132    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            // Find similar task names for suggestions
139            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    /// List all indexed tasks in deterministic order
166    pub fn list(&self) -> Vec<&IndexedTask> {
167        self.entries.values().collect()
168    }
169
170    /// Convert the index back into a Tasks collection keyed by canonical names
171    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
182/// Extract source file from a task node
183fn 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            // For groups, use source from first child task
188            group.children.values().next().and_then(extract_source_file)
189        }
190        TaskNode::Sequence(steps) => {
191            // For sequences, use source from first step
192            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                // For children, extract their own source file and use display name
225                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            // Preserve sequential children order; dependencies inside them remain as-is
260            let mut canon_children = Vec::with_capacity(steps.len());
261            for child in steps {
262                // We still recurse so nested parallel groups are indexed, but we do not
263                // rewrite names with numeric indices to avoid changing existing graph semantics.
264                // For sequential children, extract their source file
265                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    // Tasks resolved from TaskRef placeholders have their own dependency context (their
291    // deps are relative to the referenced task name, not the placeholder name). Avoid
292    // re-canonicalizing dependencies under the placeholder namespace.
293    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(); // relative to the parent namespace
314    segments.push(dep.to_string());
315
316    let rel = TaskPath { segments };
317    Ok(rel.canonical())
318}
319
320/// Check if two task names are similar (for typo suggestions)
321fn is_similar(input: &str, candidate: &str) -> bool {
322    // Exact prefix match
323    if candidate.starts_with(input) || input.starts_with(candidate) {
324        return true;
325    }
326
327    // Simple edit distance check for short strings
328    let input_lower = input.to_lowercase();
329    let candidate_lower = candidate.to_lowercase();
330
331    // Check if they share a common prefix of at least 3 chars
332    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    // Check Levenshtein distance for short names
342    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
350/// Simple Levenshtein distance implementation
351fn 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    // ==========================================================================
388    // TaskPath tests
389    // ==========================================================================
390
391    #[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    // ==========================================================================
453    // validate_segment tests
454    // ==========================================================================
455
456    #[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    // ==========================================================================
480    // TaskIndex tests
481    // ==========================================================================
482
483    #[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        // Should be accessible without underscore
516        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        // Can resolve with dots
543        assert!(index.resolve("test.unit").is_ok());
544        // Can resolve with colons
545        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        // Typo: "buld" instead of "build"
574        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        // BTreeMap should give alphabetical order
611        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    // ==========================================================================
634    // is_similar and levenshtein tests
635    // ==========================================================================
636
637    #[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")); // 1 deletion
652        assert!(is_similar("test", "tset")); // 1 transposition
653        assert!(is_similar("task", "taks")); // 1 transposition
654    }
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); // substitution
677        assert_eq!(levenshtein("cat", "cats"), 1); // insertion
678        assert_eq!(levenshtein("cats", "cat"), 1); // deletion
679    }
680
681    #[test]
682    fn test_levenshtein_multiple_edits() {
683        assert_eq!(levenshtein("kitten", "sitting"), 3);
684    }
685
686    // ==========================================================================
687    // IndexedTask tests
688    // ==========================================================================
689
690    #[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    // ==========================================================================
721    // WorkspaceTask tests
722    // ==========================================================================
723
724    #[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    // ==========================================================================
755    // TaskPath additional tests
756    // ==========================================================================
757
758    #[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}