cuenv_core/tasks/
index.rs

1use super::{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    pub name: String,
73    pub definition: TaskDefinition,
74    pub is_group: bool,
75}
76
77/// Flattened index of all addressable tasks with canonical names
78#[derive(Debug, Clone, Default)]
79pub struct TaskIndex {
80    entries: BTreeMap<String, IndexedTask>,
81}
82
83impl TaskIndex {
84    /// Build a canonical index from the hierarchical task map
85    pub fn build(tasks: &HashMap<String, TaskDefinition>) -> Result<Self> {
86        let mut entries = BTreeMap::new();
87
88        for (name, definition) in tasks {
89            let path = TaskPath::parse(name)?;
90            let _ = canonicalize_definition(definition, &path, &mut entries)?;
91        }
92
93        Ok(Self { entries })
94    }
95
96    /// Resolve a raw task name (dot or colon separated) to an indexed task
97    pub fn resolve(&self, raw: &str) -> Result<&IndexedTask> {
98        let path = TaskPath::parse(raw)?;
99        let canonical = path.canonical();
100        self.entries.get(&canonical).ok_or_else(|| {
101            let available: Vec<&str> = self.entries.keys().map(String::as_str).collect();
102
103            // Find similar task names for suggestions
104            let suggestions: Vec<&str> = available
105                .iter()
106                .filter(|t| is_similar(&canonical, t))
107                .copied()
108                .collect();
109
110            let mut msg = format!("Task '{}' not found.", canonical);
111
112            if !suggestions.is_empty() {
113                msg.push_str("\n\nDid you mean one of these?\n");
114                for s in &suggestions {
115                    msg.push_str(&format!("  - {s}\n"));
116                }
117            }
118
119            if !available.is_empty() {
120                msg.push_str("\nAvailable tasks:\n");
121                for t in &available {
122                    msg.push_str(&format!("  - {t}\n"));
123                }
124            }
125
126            Error::configuration(msg)
127        })
128    }
129
130    /// List all indexed tasks in deterministic order
131    pub fn list(&self) -> Vec<&IndexedTask> {
132        self.entries.values().collect()
133    }
134
135    /// Convert the index back into a Tasks collection keyed by canonical names
136    pub fn to_tasks(&self) -> Tasks {
137        let tasks = self
138            .entries
139            .iter()
140            .map(|(name, entry)| (name.clone(), entry.definition.clone()))
141            .collect();
142
143        Tasks { tasks }
144    }
145}
146
147fn canonicalize_definition(
148    definition: &TaskDefinition,
149    path: &TaskPath,
150    entries: &mut BTreeMap<String, IndexedTask>,
151) -> Result<TaskDefinition> {
152    match definition {
153        TaskDefinition::Single(task) => {
154            let canon_task = canonicalize_task(task.as_ref(), path)?;
155            let name = path.canonical();
156            entries.insert(
157                name.clone(),
158                IndexedTask {
159                    name,
160                    definition: TaskDefinition::Single(Box::new(canon_task.clone())),
161                    is_group: false,
162                },
163            );
164            Ok(TaskDefinition::Single(Box::new(canon_task)))
165        }
166        TaskDefinition::Group(group) => match group {
167            TaskGroup::Parallel(children) => {
168                let mut canon_children = HashMap::new();
169                for (child_name, child_def) in children {
170                    let child_path = path.join(child_name)?;
171                    let canon_child = canonicalize_definition(child_def, &child_path, entries)?;
172                    canon_children.insert(child_name.clone(), canon_child);
173                }
174
175                let name = path.canonical();
176                let definition = TaskDefinition::Group(TaskGroup::Parallel(canon_children));
177                entries.insert(
178                    name.clone(),
179                    IndexedTask {
180                        name,
181                        definition: definition.clone(),
182                        is_group: true,
183                    },
184                );
185
186                Ok(definition)
187            }
188            TaskGroup::Sequential(children) => {
189                // Preserve sequential children order; dependencies inside them remain as-is
190                let mut canon_children = Vec::with_capacity(children.len());
191                for child in children {
192                    // We still recurse so nested parallel groups are indexed, but we do not
193                    // rewrite names with numeric indices to avoid changing existing graph semantics.
194                    let canon_child = canonicalize_definition(child, path, entries)?;
195                    canon_children.push(canon_child);
196                }
197
198                let name = path.canonical();
199                let definition = TaskDefinition::Group(TaskGroup::Sequential(canon_children));
200                entries.insert(
201                    name.clone(),
202                    IndexedTask {
203                        name,
204                        definition: definition.clone(),
205                        is_group: true,
206                    },
207                );
208
209                Ok(definition)
210            }
211        },
212    }
213}
214
215fn canonicalize_task(task: &Task, path: &TaskPath) -> Result<Task> {
216    let mut clone = task.clone();
217    let mut canonical_deps = Vec::new();
218    for dep in &task.depends_on {
219        canonical_deps.push(canonicalize_dep(dep, path)?);
220    }
221    clone.depends_on = canonical_deps;
222    Ok(clone)
223}
224
225fn canonicalize_dep(dep: &str, current_path: &TaskPath) -> Result<String> {
226    if dep.contains('.') || dep.contains(':') {
227        return Ok(TaskPath::parse(dep)?.canonical());
228    }
229
230    let mut segments: Vec<String> = current_path.segments().to_vec();
231    segments.pop(); // relative to the parent namespace
232    segments.push(dep.to_string());
233
234    let rel = TaskPath { segments };
235    Ok(rel.canonical())
236}
237
238/// Check if two task names are similar (for typo suggestions)
239fn is_similar(input: &str, candidate: &str) -> bool {
240    // Exact prefix match
241    if candidate.starts_with(input) || input.starts_with(candidate) {
242        return true;
243    }
244
245    // Simple edit distance check for short strings
246    let input_lower = input.to_lowercase();
247    let candidate_lower = candidate.to_lowercase();
248
249    // Check if they share a common prefix of at least 3 chars
250    let common_prefix = input_lower
251        .chars()
252        .zip(candidate_lower.chars())
253        .take_while(|(a, b)| a == b)
254        .count();
255    if common_prefix >= 3 {
256        return true;
257    }
258
259    // Check Levenshtein distance for short names
260    if input.len() <= 10 && candidate.len() <= 10 {
261        let distance = levenshtein(&input_lower, &candidate_lower);
262        return distance <= 2;
263    }
264
265    false
266}
267
268/// Simple Levenshtein distance implementation
269fn levenshtein(a: &str, b: &str) -> usize {
270    let a_chars: Vec<char> = a.chars().collect();
271    let b_chars: Vec<char> = b.chars().collect();
272    let m = a_chars.len();
273    let n = b_chars.len();
274
275    if m == 0 {
276        return n;
277    }
278    if n == 0 {
279        return m;
280    }
281
282    let mut prev: Vec<usize> = (0..=n).collect();
283    let mut curr = vec![0; n + 1];
284
285    for i in 1..=m {
286        curr[0] = i;
287        for j in 1..=n {
288            let cost = if a_chars[i - 1] == b_chars[j - 1] {
289                0
290            } else {
291                1
292            };
293            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
294        }
295        std::mem::swap(&mut prev, &mut curr);
296    }
297
298    prev[n]
299}