Skip to main content

task_commander/
tasks.rs

1use super::errors::{
2    Result, err_dependency_cycle, err_duplicated_task, err_task_not_found, err_unexpected_node,
3    err_zero_or_one_attribute_allowed,
4};
5use crate::command::execute;
6use antex::{ColorMode, StyledText, Text};
7use petgraph::Graph;
8use petgraph::algo::toposort;
9use petgraph::graph::NodeIndex;
10use std::collections::{BTreeMap, HashMap};
11use std::process::ExitStatus;
12
13/// An action to be performed.
14#[derive(Debug, Clone)]
15pub enum Action {
16    Command(
17        /// Shell command to be executed.
18        String,
19        /// Custom working directory.
20        String,
21    ),
22    Task(
23        /// Subtask name.
24        String,
25    ),
26}
27
28/// A task.
29#[derive(Debug, Default, Clone)]
30pub struct Task {
31    /// Task name.
32    name: String,
33    /// Task description.
34    description: Option<String>,
35    /// Expected actions to be performed to complete this task.
36    actions: Vec<Action>,
37    /// Shell commands to be executed to complete this task.
38    commands: Vec<(String, String)>,
39}
40
41impl Task {
42    /// Creates a new task with a give name.
43    #[must_use]
44    pub fn new(name: String) -> Self {
45        Self {
46            name,
47            ..Default::default()
48        }
49    }
50
51    /// Returns task name.
52    #[must_use]
53    pub fn get_name(&self) -> &str {
54        &self.name
55    }
56
57    /// Returns task description.
58    #[must_use]
59    pub const fn get_description(&self) -> &Option<String> {
60        &self.description
61    }
62
63    /// Sets task description.
64    pub fn set_description(&mut self, description: String) {
65        self.description = Some(description);
66    }
67
68    /// Adds a new command to this task.
69    pub fn add_command(&mut self, command: String, dir: String) {
70        self.actions.push(Action::Command(command, dir));
71    }
72
73    /// Adds a new subtask to this task.
74    pub fn add_task(&mut self, task: String) {
75        self.actions.push(Action::Task(task));
76    }
77}
78
79/// A collection of tasks.
80#[derive(Debug)]
81pub struct Tasks {
82    tasks: BTreeMap<String, Task>,
83    variables: HashMap<String, String>,
84}
85
86impl Tasks {
87    /// Creates executable tasks from definitions.
88    ///
89    /// # Errors
90    ///
91    /// (tbd)
92    ///
93    /// # Panics
94    ///
95    /// TODO: Get rid of panics
96    pub fn new(definitions: &str) -> Result<Self> {
97        let mut tasks = Self::parse_definitions(definitions)?;
98        let ordered_names = Self::sort_tasks_topologically(&tasks)?;
99        for name in &ordered_names {
100            let mut commands = vec![];
101            let task = tasks.tasks.get(name).unwrap(); // TODO get rid of unwrap
102            for action in &task.actions {
103                match action {
104                    Action::Command(command, dir) => {
105                        commands.push((interpolate(command, &tasks.variables), dir.clone()));
106                    }
107                    Action::Task(name) => {
108                        let subtask = tasks.tasks.get(name).unwrap(); // TODO get rid of unwrap
109                        commands.append(&mut subtask.commands.clone());
110                    }
111                }
112            }
113            let task = tasks.tasks.get_mut(name).unwrap(); // TODO get rid of unwrap
114            task.commands.clear();
115            task.commands.append(&mut commands);
116        }
117        Ok(tasks)
118    }
119
120    /// Executes all commands of the given task.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if there is no task with specified name.
125    pub fn execute(&self, name: &str) -> Result<ExitStatus> {
126        let mut exit_status = ExitStatus::default();
127        if let Some(task) = self.tasks.get(name) {
128            for (command, dir) in &task.commands {
129                exit_status = execute(command, dir)?;
130                if !exit_status.success() {
131                    break;
132                }
133            }
134        } else {
135            return Err(err_task_not_found(name.to_string()));
136        }
137        Ok(exit_status)
138    }
139
140    /// Displays a list of tasks.
141    pub fn list(&self, cm: ColorMode) {
142        let width = self.tasks.keys().map(String::len).max().unwrap_or_default();
143        println!(
144            "{}",
145            Text::new(cm)
146                .bold()
147                .underline()
148                .s("Available tasks")
149                .reset()
150        );
151        for task in self.tasks.values() {
152            let name = task.get_name();
153            if let Some(description) = task.get_description() {
154                print!(
155                    "  {}  {}",
156                    Text::new(cm).bright_green().align_left(name, width).reset(),
157                    description.lines().take(1).collect::<String>()
158                );
159            } else {
160                print!("  {}", Text::new(cm).bright_green().s(name).reset());
161            }
162            println!();
163        }
164    }
165
166    /// Parses task definitions.
167    fn parse_definitions(definitions: &str) -> Result<Self> {
168        let mut tasks = Self {
169            tasks: BTreeMap::new(),
170            variables: HashMap::new(),
171        };
172        let root_node = diml::parse(definitions).expect("failed to parse the task file");
173        for top_level_node in root_node.children() {
174            match top_level_node.name() {
175                "note" => {} // Global notes are treated as a comment.
176                "vars" => {
177                    // Global variables.
178                    for variable_node in top_level_node.children() {
179                        for task_attribute_node in variable_node.children() {
180                            match task_attribute_node.name() {
181                                "note" => {} // Variable notes are treated as a comment.
182                                other => return Err(err_unexpected_node(other.to_string())),
183                            }
184                        }
185                        tasks.variables.insert(
186                            variable_node.name().to_string(),
187                            variable_node.content().trim().to_string(),
188                        );
189                    }
190                }
191                "tasks" => {
192                    // Task definitions.
193                    for task_node in top_level_node.children() {
194                        let task_name = task_node.name().to_string();
195                        if tasks.tasks.contains_key(&task_name) {
196                            return Err(err_duplicated_task(task_name));
197                        }
198                        let mut task = Task::new(task_name.clone());
199                        for task_attribute_node in task_node.children() {
200                            match task_attribute_node.name() {
201                                "note" => {} // Task notes are treated as a comment.
202                                "desc" => {
203                                    task.set_description(
204                                        task_attribute_node.content().trim().to_string(),
205                                    );
206                                }
207                                "task" => {
208                                    task.add_task(task_attribute_node.content().trim().to_string());
209                                }
210                                "cmd" => {
211                                    let command_text = task_attribute_node.content().trim();
212                                    let mut dir = None;
213                                    for command_attribute_node in task_attribute_node.children() {
214                                        match command_attribute_node.name() {
215                                            "note" => {} // Command notes are treated as a comment.
216                                            v @ "dir" if dir.is_some() => {
217                                                return Err(err_zero_or_one_attribute_allowed(
218                                                    v.to_string(),
219                                                ));
220                                            }
221                                            "dir" => {
222                                                dir = Some(
223                                                    command_attribute_node
224                                                        .content()
225                                                        .trim()
226                                                        .to_string(),
227                                                );
228                                            }
229                                            other => {
230                                                return Err(err_unexpected_node(other.to_string()));
231                                            }
232                                        }
233                                    }
234                                    task.add_command(
235                                        command_text.to_string(),
236                                        dir.unwrap_or_else(|| ".".to_string()),
237                                    );
238                                }
239                                other => return Err(err_unexpected_node(other.to_string())),
240                            }
241                        }
242                        tasks.tasks.insert(task_name.clone(), task);
243                    }
244                }
245                other => return Err(err_unexpected_node(other.to_string())),
246            }
247        }
248        Ok(tasks)
249    }
250
251    /// Sorts tasks topologically, detect non-existing subtasks and dependency cycles.
252    fn sort_tasks_topologically(tasks: &Self) -> Result<Vec<String>> {
253        let mut graph = Graph::<String, ()>::new();
254        let mut name_by_index: HashMap<NodeIndex, String> = HashMap::new();
255        let mut index_by_name: HashMap<String, NodeIndex> = HashMap::new();
256        // Add all nodes to the graph.
257        for (name, _) in tasks {
258            let node_index = graph.add_node(name.to_owned());
259            name_by_index.insert(node_index, name.to_owned());
260            index_by_name.insert(name.to_owned(), node_index);
261        }
262        // Add directed edges to the graph, from child node to parent node.
263        for (name, task) in tasks {
264            let parent_index = index_by_name.get(name).copied().unwrap();
265            for action in &task.actions {
266                if let Action::Task(name) = action {
267                    let Some(child_index) = index_by_name.get(name).copied() else {
268                        return Err(err_task_not_found(name.to_owned()));
269                    };
270                    graph.add_edge(child_index, parent_index, ());
271                }
272            }
273        }
274        // Sort topologically, detect cycles.
275        match toposort(&graph, None) {
276            Ok(indexes) => Ok(indexes
277                .iter()
278                .map(|index| name_by_index.get(index).cloned().unwrap())
279                .collect()),
280            Err(cycle) => Err(err_dependency_cycle(
281                name_by_index.get(&cycle.node_id()).cloned().unwrap(),
282            )),
283        }
284    }
285
286    fn iter(&self) -> std::collections::btree_map::Iter<'_, String, Task> {
287        self.tasks.iter()
288    }
289}
290
291/// Interpolates variables in the given string.
292fn interpolate(input: &str, global_variables: &HashMap<String, String>) -> String {
293    let mut interpolated = input.to_string();
294    for (name, value) in global_variables {
295        let pattern = format!("{{{{{name}}}}}");
296        interpolated = interpolated.replace(&pattern, value);
297    }
298    interpolated
299}
300
301impl<'a> IntoIterator for &'a Tasks {
302    type Item = (&'a String, &'a Task);
303    type IntoIter = std::collections::btree_map::Iter<'a, String, Task>;
304
305    fn into_iter(self) -> Self::IntoIter {
306        self.iter()
307    }
308}