Skip to main content

cuenv_core/tasks/
index.rs

1use super::{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            // Inline from canonicalize_task: Tasks resolved from TaskRef placeholders have
207            // their own dependency context. Avoid re-canonicalizing under placeholder namespace.
208            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                // For children, extract their own source file and use display name
239                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            // Preserve sequential children order; dependencies inside them remain as-is
274            let mut canon_children = Vec::with_capacity(steps.len());
275            for child in steps {
276                // We still recurse so nested parallel groups are indexed, but we do not
277                // rewrite names with numeric indices to avoid changing existing graph semantics.
278                // For sequential children, extract their source file
279                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    // The Go bridge now provides canonical task paths via ReferencePath(),
305    // so we simply parse and normalize the dependency name.
306    // No lookups needed - trust the _name injected by CUE evaluation.
307    Ok(TaskPath::parse(dep)?.canonical())
308}
309
310/// Check if two task names are similar (for typo suggestions)
311fn is_similar(input: &str, candidate: &str) -> bool {
312    // Exact prefix match
313    if candidate.starts_with(input) || input.starts_with(candidate) {
314        return true;
315    }
316
317    // Simple edit distance check for short strings
318    let input_lower = input.to_lowercase();
319    let candidate_lower = candidate.to_lowercase();
320
321    // Check if they share a common prefix of at least 3 chars
322    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    // Check Levenshtein distance for short names
332    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
340/// Simple Levenshtein distance implementation
341fn 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    // ==========================================================================
379    // TaskPath tests
380    // ==========================================================================
381
382    #[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    // ==========================================================================
444    // validate_segment tests
445    // ==========================================================================
446
447    #[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    // ==========================================================================
471    // TaskIndex tests
472    // ==========================================================================
473
474    #[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        // Should be accessible without underscore
507        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        // Can resolve with dots
534        assert!(index.resolve("test.unit").is_ok());
535        // Can resolve with colons
536        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        // Typo: "buld" instead of "build"
565        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        // BTreeMap should give alphabetical order
602        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    // ==========================================================================
625    // is_similar and levenshtein tests
626    // ==========================================================================
627
628    #[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")); // 1 deletion
643        assert!(is_similar("test", "tset")); // 1 transposition
644        assert!(is_similar("task", "taks")); // 1 transposition
645    }
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); // substitution
668        assert_eq!(levenshtein("cat", "cats"), 1); // insertion
669        assert_eq!(levenshtein("cats", "cat"), 1); // deletion
670    }
671
672    #[test]
673    fn test_levenshtein_multiple_edits() {
674        assert_eq!(levenshtein("kitten", "sitting"), 3);
675    }
676
677    // ==========================================================================
678    // IndexedTask tests
679    // ==========================================================================
680
681    #[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    // ==========================================================================
712    // WorkspaceTask tests
713    // ==========================================================================
714
715    #[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    // ==========================================================================
746    // TaskPath additional tests
747    // ==========================================================================
748
749    #[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    // ==========================================================================
765    // Dependency resolution tests (bug fix: group child -> top-level task)
766    // ==========================================================================
767
768    #[test]
769    fn test_task_index_preserves_dependency_names_as_given() {
770        // TaskIndex preserves whatever dependency name it receives.
771        // Reference resolution happens BEFORE TaskIndex (in module.rs enrichment).
772        // This test validates TaskIndex's raw behavior in isolation.
773
774        let mut tasks = HashMap::new();
775
776        // Top-level build task
777        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        // Deploy group with preview child - dependency name is pre-resolved
786        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                // In practice, enrichment resolves this before TaskIndex sees it.
792                // This tests that TaskIndex preserves the name as given.
793                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                // TaskIndex preserves names as given - resolution happens earlier
815                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        // When using a qualified path like "deploy.upload", TaskIndex preserves it as-is.
824        // Enrichment (module.rs) resolves short names to qualified paths before TaskIndex.
825
826        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                // Qualified path - either from CUE source or enrichment resolution
841                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        // deploy.preview depends on "other.task" -> treated as absolute path
871
872        let mut tasks = HashMap::new();
873
874        // other.task (as group child)
875        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        // Deploy group
895        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        // deploy.run depends on "build.compile" -> absolute path to build.compile
930
931        let mut tasks = HashMap::new();
932
933        // Build group
934        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        // Deploy group
954        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        // TaskIndex preserves all dependency names as given, even invalid ones.
989        // Validation (missing task detection) happens later during graph building.
990        // This tests TaskIndex in isolation.
991
992        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                // Invalid reference - no such task exists
1000                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                // TaskIndex preserves names as given - validation happens at graph build time
1021                assert_eq!(task.depends_on[0].task_name(), "nonexistent");
1022            }
1023            _ => panic!("Expected Task"),
1024        }
1025    }
1026}