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