1use super::{ParallelGroup, 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,
74 pub original_name: String,
76 pub definition: TaskDefinition,
77 pub is_group: bool,
78 pub source_file: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize)]
84pub struct WorkspaceTask {
85 pub project: String,
87 pub task: String,
89 pub task_ref: String,
91 pub description: Option<String>,
93 pub is_group: bool,
95}
96
97#[derive(Debug, Clone, Default)]
99pub struct TaskIndex {
100 entries: BTreeMap<String, IndexedTask>,
101}
102
103impl TaskIndex {
104 pub fn build(tasks: &HashMap<String, TaskDefinition>) -> Result<Self> {
111 let mut entries = BTreeMap::new();
112
113 for (name, definition) in tasks {
114 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 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 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 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 pub fn list(&self) -> Vec<&IndexedTask> {
173 self.entries.values().collect()
174 }
175
176 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
188fn 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 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 ¶llel.tasks {
231 let child_path = path.join(child_name)?;
232 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 let mut canon_children = Vec::with_capacity(children.len());
266 for child in children {
267 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 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(); segments.push(dep.to_string());
325
326 let rel = TaskPath { segments };
327 Ok(rel.canonical())
328}
329
330fn is_similar(input: &str, candidate: &str) -> bool {
332 if candidate.starts_with(input) || input.starts_with(candidate) {
334 return true;
335 }
336
337 let input_lower = input.to_lowercase();
339 let candidate_lower = candidate.to_lowercase();
340
341 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 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
360fn 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}