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, Default)]
84pub struct TaskIndex {
85 entries: BTreeMap<String, IndexedTask>,
86}
87
88impl TaskIndex {
89 pub fn build(tasks: &HashMap<String, TaskDefinition>) -> Result<Self> {
96 let mut entries = BTreeMap::new();
97
98 for (name, definition) in tasks {
99 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 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 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 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 pub fn list(&self) -> Vec<&IndexedTask> {
158 self.entries.values().collect()
159 }
160
161 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
173fn 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 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 ¶llel.tasks {
216 let child_path = path.join(child_name)?;
217 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 let mut canon_children = Vec::with_capacity(children.len());
251 for child in children {
252 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 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(); segments.push(dep.to_string());
310
311 let rel = TaskPath { segments };
312 Ok(rel.canonical())
313}
314
315fn is_similar(input: &str, candidate: &str) -> bool {
317 if candidate.starts_with(input) || input.starts_with(candidate) {
319 return true;
320 }
321
322 let input_lower = input.to_lowercase();
324 let candidate_lower = candidate.to_lowercase();
325
326 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 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
345fn 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}