cuenv_core/tasks/
index.rs1use super::{Task, TaskDefinition, TaskGroup, Tasks};
2use crate::{Error, Result};
3use serde::Serialize;
4use std::collections::{BTreeMap, HashMap};
5
6#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
8pub struct TaskPath {
9 segments: Vec<String>,
10}
11
12impl TaskPath {
13 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 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 pub fn canonical(&self) -> String {
47 self.segments.join(".")
48 }
49
50 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#[derive(Debug, Clone, Default)]
79pub struct TaskIndex {
80 entries: BTreeMap<String, IndexedTask>,
81}
82
83impl TaskIndex {
84 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 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 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 pub fn list(&self) -> Vec<&IndexedTask> {
132 self.entries.values().collect()
133 }
134
135 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 let mut canon_children = Vec::with_capacity(children.len());
191 for child in children {
192 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(); segments.push(dep.to_string());
233
234 let rel = TaskPath { segments };
235 Ok(rel.canonical())
236}
237
238fn is_similar(input: &str, candidate: &str) -> bool {
240 if candidate.starts_with(input) || input.starts_with(candidate) {
242 return true;
243 }
244
245 let input_lower = input.to_lowercase();
247 let candidate_lower = candidate.to_lowercase();
248
249 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 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
268fn 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}