cuenv_core/tasks/
index.rs

1use super::{ParallelGroup, Task, TaskDefinition, TaskGroup, 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 definition: TaskDefinition,
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, TaskDefinition>) -> Result<Self> {
111        let mut entries = BTreeMap::new();
112
113        for (name, definition) 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 definition
122            let source_file = extract_source_file(definition);
123
124            let path = TaskPath::parse(&display_name)?;
125            let _ = canonicalize_definition(
126                definition,
127                &path,
128                &mut entries,
129                original_name,
130                source_file,
131            )?;
132        }
133
134        Ok(Self { entries })
135    }
136
137    /// Resolve a raw task name (dot or colon separated) to an indexed task
138    pub fn resolve(&self, raw: &str) -> Result<&IndexedTask> {
139        let path = TaskPath::parse(raw)?;
140        let canonical = path.canonical();
141        self.entries.get(&canonical).ok_or_else(|| {
142            let available: Vec<&str> = self.entries.keys().map(String::as_str).collect();
143
144            // Find similar task names for suggestions
145            let suggestions: Vec<&str> = available
146                .iter()
147                .filter(|t| is_similar(&canonical, t))
148                .copied()
149                .collect();
150
151            let mut msg = format!("Task '{}' not found.", canonical);
152
153            if !suggestions.is_empty() {
154                msg.push_str("\n\nDid you mean one of these?\n");
155                for s in &suggestions {
156                    msg.push_str(&format!("  - {s}\n"));
157                }
158            }
159
160            if !available.is_empty() {
161                msg.push_str("\nAvailable tasks:\n");
162                for t in &available {
163                    msg.push_str(&format!("  - {t}\n"));
164                }
165            }
166
167            Error::configuration(msg)
168        })
169    }
170
171    /// List all indexed tasks in deterministic order
172    pub fn list(&self) -> Vec<&IndexedTask> {
173        self.entries.values().collect()
174    }
175
176    /// Convert the index back into a Tasks collection keyed by canonical names
177    pub fn to_tasks(&self) -> Tasks {
178        let tasks = self
179            .entries
180            .iter()
181            .map(|(name, entry)| (name.clone(), entry.definition.clone()))
182            .collect();
183
184        Tasks { tasks }
185    }
186}
187
188/// Extract source file from a task definition
189fn extract_source_file(definition: &TaskDefinition) -> Option<String> {
190    match definition {
191        TaskDefinition::Single(task) => task.source.as_ref().map(|s| s.file.clone()),
192        TaskDefinition::Group(group) => {
193            // For groups, use source from first child task
194            match group {
195                TaskGroup::Sequential(tasks) => tasks.first().and_then(extract_source_file),
196                TaskGroup::Parallel(parallel) => {
197                    parallel.tasks.values().next().and_then(extract_source_file)
198                }
199            }
200        }
201    }
202}
203
204fn canonicalize_definition(
205    definition: &TaskDefinition,
206    path: &TaskPath,
207    entries: &mut BTreeMap<String, IndexedTask>,
208    original_name: String,
209    source_file: Option<String>,
210) -> Result<TaskDefinition> {
211    match definition {
212        TaskDefinition::Single(task) => {
213            let canon_task = canonicalize_task(task.as_ref(), path)?;
214            let name = path.canonical();
215            entries.insert(
216                name.clone(),
217                IndexedTask {
218                    name,
219                    original_name,
220                    definition: TaskDefinition::Single(Box::new(canon_task.clone())),
221                    is_group: false,
222                    source_file,
223                },
224            );
225            Ok(TaskDefinition::Single(Box::new(canon_task)))
226        }
227        TaskDefinition::Group(group) => match group {
228            TaskGroup::Parallel(parallel) => {
229                let mut canon_children = HashMap::new();
230                for (child_name, child_def) in &parallel.tasks {
231                    let child_path = path.join(child_name)?;
232                    // For children, extract their own source file and use display name
233                    let child_source = extract_source_file(child_def);
234                    let child_original = child_name.clone();
235                    let canon_child = canonicalize_definition(
236                        child_def,
237                        &child_path,
238                        entries,
239                        child_original,
240                        child_source,
241                    )?;
242                    canon_children.insert(child_name.clone(), canon_child);
243                }
244
245                let name = path.canonical();
246                let definition = TaskDefinition::Group(TaskGroup::Parallel(ParallelGroup {
247                    tasks: canon_children,
248                    depends_on: parallel.depends_on.clone(),
249                }));
250                entries.insert(
251                    name.clone(),
252                    IndexedTask {
253                        name,
254                        original_name,
255                        definition: definition.clone(),
256                        is_group: true,
257                        source_file,
258                    },
259                );
260
261                Ok(definition)
262            }
263            TaskGroup::Sequential(children) => {
264                // Preserve sequential children order; dependencies inside them remain as-is
265                let mut canon_children = Vec::with_capacity(children.len());
266                for child in children {
267                    // We still recurse so nested parallel groups are indexed, but we do not
268                    // rewrite names with numeric indices to avoid changing existing graph semantics.
269                    // For sequential children, extract their source file
270                    let child_source = extract_source_file(child);
271                    let canon_child = canonicalize_definition(
272                        child,
273                        path,
274                        entries,
275                        original_name.clone(),
276                        child_source,
277                    )?;
278                    canon_children.push(canon_child);
279                }
280
281                let name = path.canonical();
282                let definition = TaskDefinition::Group(TaskGroup::Sequential(canon_children));
283                entries.insert(
284                    name.clone(),
285                    IndexedTask {
286                        name,
287                        original_name,
288                        definition: definition.clone(),
289                        is_group: true,
290                        source_file,
291                    },
292                );
293
294                Ok(definition)
295            }
296        },
297    }
298}
299
300fn canonicalize_task(task: &Task, path: &TaskPath) -> Result<Task> {
301    // Tasks resolved from TaskRef placeholders have their own dependency context (their
302    // deps are relative to the referenced task name, not the placeholder name). Avoid
303    // re-canonicalizing dependencies under the placeholder namespace.
304    if task.project_root.is_some() && task.task_ref.is_none() {
305        return Ok(task.clone());
306    }
307
308    let mut clone = task.clone();
309    let mut canonical_deps = Vec::new();
310    for dep in &task.depends_on {
311        canonical_deps.push(canonicalize_dep(dep, path)?);
312    }
313    clone.depends_on = canonical_deps;
314    Ok(clone)
315}
316
317fn canonicalize_dep(dep: &str, current_path: &TaskPath) -> Result<String> {
318    if dep.contains('.') || dep.contains(':') {
319        return Ok(TaskPath::parse(dep)?.canonical());
320    }
321
322    let mut segments: Vec<String> = current_path.segments().to_vec();
323    segments.pop(); // relative to the parent namespace
324    segments.push(dep.to_string());
325
326    let rel = TaskPath { segments };
327    Ok(rel.canonical())
328}
329
330/// Check if two task names are similar (for typo suggestions)
331fn is_similar(input: &str, candidate: &str) -> bool {
332    // Exact prefix match
333    if candidate.starts_with(input) || input.starts_with(candidate) {
334        return true;
335    }
336
337    // Simple edit distance check for short strings
338    let input_lower = input.to_lowercase();
339    let candidate_lower = candidate.to_lowercase();
340
341    // Check if they share a common prefix of at least 3 chars
342    let common_prefix = input_lower
343        .chars()
344        .zip(candidate_lower.chars())
345        .take_while(|(a, b)| a == b)
346        .count();
347    if common_prefix >= 3 {
348        return true;
349    }
350
351    // Check Levenshtein distance for short names
352    if input.len() <= 10 && candidate.len() <= 10 {
353        let distance = levenshtein(&input_lower, &candidate_lower);
354        return distance <= 2;
355    }
356
357    false
358}
359
360/// Simple Levenshtein distance implementation
361fn levenshtein(a: &str, b: &str) -> usize {
362    let a_chars: Vec<char> = a.chars().collect();
363    let b_chars: Vec<char> = b.chars().collect();
364    let m = a_chars.len();
365    let n = b_chars.len();
366
367    if m == 0 {
368        return n;
369    }
370    if n == 0 {
371        return m;
372    }
373
374    let mut prev: Vec<usize> = (0..=n).collect();
375    let mut curr = vec![0; n + 1];
376
377    for i in 1..=m {
378        curr[0] = i;
379        for j in 1..=n {
380            let cost = if a_chars[i - 1] == b_chars[j - 1] {
381                0
382            } else {
383                1
384            };
385            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
386        }
387        std::mem::swap(&mut prev, &mut curr);
388    }
389
390    prev[n]
391}