cuenv_core/tasks/
index.rs

1use super::{Task, TaskDefinition, TaskGroup, Tasks};
2use crate::{Error, Result};
3use std::collections::{BTreeMap, HashMap};
4
5/// Parsed task path that normalizes dotted/colon-separated identifiers
6#[derive(Debug, Clone, Eq, PartialEq)]
7pub struct TaskPath {
8    segments: Vec<String>,
9}
10
11impl TaskPath {
12    /// Parse a raw task path that may use '.' or ':' separators
13    pub fn parse(raw: &str) -> Result<Self> {
14        if raw.trim().is_empty() {
15            return Err(Error::configuration("Task name cannot be empty"));
16        }
17
18        let normalized = raw.replace(':', ".");
19        let segments: Vec<String> = normalized
20            .split('.')
21            .filter(|s| !s.is_empty())
22            .map(|s| s.trim().to_string())
23            .collect();
24
25        if segments.is_empty() {
26            return Err(Error::configuration("Task name cannot be empty"));
27        }
28
29        for segment in &segments {
30            validate_segment(segment)?;
31        }
32
33        Ok(Self { segments })
34    }
35
36    /// Create a new path with an additional segment appended
37    pub fn join(&self, segment: &str) -> Result<Self> {
38        validate_segment(segment)?;
39        let mut next = self.segments.clone();
40        next.push(segment.to_string());
41        Ok(Self { segments: next })
42    }
43
44    /// Convert to canonical dotted representation
45    pub fn canonical(&self) -> String {
46        self.segments.join(".")
47    }
48
49    /// Return the underlying path segments
50    pub fn segments(&self) -> &[String] {
51        &self.segments
52    }
53}
54
55fn validate_segment(segment: &str) -> Result<()> {
56    if segment.is_empty() {
57        return Err(Error::configuration("Task name segment cannot be empty"));
58    }
59
60    if segment.contains('.') || segment.contains(':') {
61        return Err(Error::configuration(format!(
62            "Task name segment '{segment}' may not contain '.' or ':'"
63        )));
64    }
65
66    Ok(())
67}
68
69#[derive(Debug, Clone)]
70pub struct IndexedTask {
71    pub name: String,
72    pub definition: TaskDefinition,
73    pub is_group: bool,
74}
75
76/// Flattened index of all addressable tasks with canonical names
77#[derive(Debug, Clone, Default)]
78pub struct TaskIndex {
79    entries: BTreeMap<String, IndexedTask>,
80}
81
82impl TaskIndex {
83    /// Build a canonical index from the hierarchical task map
84    pub fn build(tasks: &HashMap<String, TaskDefinition>) -> Result<Self> {
85        let mut entries = BTreeMap::new();
86
87        for (name, definition) in tasks {
88            let path = TaskPath::parse(name)?;
89            let _ = canonicalize_definition(definition, &path, &mut entries)?;
90        }
91
92        Ok(Self { entries })
93    }
94
95    /// Resolve a raw task name (dot or colon separated) to an indexed task
96    pub fn resolve(&self, raw: &str) -> Result<&IndexedTask> {
97        let path = TaskPath::parse(raw)?;
98        self.entries.get(&path.canonical()).ok_or_else(|| {
99            let available: Vec<&str> = self.entries.keys().map(String::as_str).collect();
100            Error::configuration(format!(
101                "Task '{}' not found. Available tasks: {:?}",
102                path.canonical(),
103                available
104            ))
105        })
106    }
107
108    /// List all indexed tasks in deterministic order
109    pub fn list(&self) -> Vec<&IndexedTask> {
110        self.entries.values().collect()
111    }
112
113    /// Convert the index back into a Tasks collection keyed by canonical names
114    pub fn to_tasks(&self) -> Tasks {
115        let tasks = self
116            .entries
117            .iter()
118            .map(|(name, entry)| (name.clone(), entry.definition.clone()))
119            .collect();
120
121        Tasks { tasks }
122    }
123}
124
125fn canonicalize_definition(
126    definition: &TaskDefinition,
127    path: &TaskPath,
128    entries: &mut BTreeMap<String, IndexedTask>,
129) -> Result<TaskDefinition> {
130    match definition {
131        TaskDefinition::Single(task) => {
132            let canon_task = canonicalize_task(task.as_ref(), path)?;
133            let name = path.canonical();
134            entries.insert(
135                name.clone(),
136                IndexedTask {
137                    name,
138                    definition: TaskDefinition::Single(Box::new(canon_task.clone())),
139                    is_group: false,
140                },
141            );
142            Ok(TaskDefinition::Single(Box::new(canon_task)))
143        }
144        TaskDefinition::Group(group) => match group {
145            TaskGroup::Parallel(children) => {
146                let mut canon_children = HashMap::new();
147                for (child_name, child_def) in children {
148                    let child_path = path.join(child_name)?;
149                    let canon_child = canonicalize_definition(child_def, &child_path, entries)?;
150                    canon_children.insert(child_name.clone(), canon_child);
151                }
152
153                let name = path.canonical();
154                let definition = TaskDefinition::Group(TaskGroup::Parallel(canon_children));
155                entries.insert(
156                    name.clone(),
157                    IndexedTask {
158                        name,
159                        definition: definition.clone(),
160                        is_group: true,
161                    },
162                );
163
164                Ok(definition)
165            }
166            TaskGroup::Sequential(children) => {
167                // Preserve sequential children order; dependencies inside them remain as-is
168                let mut canon_children = Vec::with_capacity(children.len());
169                for child in children {
170                    // We still recurse so nested parallel groups are indexed, but we do not
171                    // rewrite names with numeric indices to avoid changing existing graph semantics.
172                    let canon_child = canonicalize_definition(child, path, entries)?;
173                    canon_children.push(canon_child);
174                }
175
176                let name = path.canonical();
177                let definition = TaskDefinition::Group(TaskGroup::Sequential(canon_children));
178                entries.insert(
179                    name.clone(),
180                    IndexedTask {
181                        name,
182                        definition: definition.clone(),
183                        is_group: true,
184                    },
185                );
186
187                Ok(definition)
188            }
189        },
190    }
191}
192
193fn canonicalize_task(task: &Task, path: &TaskPath) -> Result<Task> {
194    let mut clone = task.clone();
195    let mut canonical_deps = Vec::new();
196    for dep in &task.depends_on {
197        canonical_deps.push(canonicalize_dep(dep, path)?);
198    }
199    clone.depends_on = canonical_deps;
200    Ok(clone)
201}
202
203fn canonicalize_dep(dep: &str, current_path: &TaskPath) -> Result<String> {
204    if dep.contains('.') || dep.contains(':') {
205        return Ok(TaskPath::parse(dep)?.canonical());
206    }
207
208    let mut segments: Vec<String> = current_path.segments().to_vec();
209    segments.pop(); // relative to the parent namespace
210    segments.push(dep.to_string());
211
212    let rel = TaskPath { segments };
213    Ok(rel.canonical())
214}