Skip to main content

robin/scripts/
script_runner.rs

1use anyhow::{Context, Result, anyhow};
2use colored::*;
3use fuzzy_matcher::FuzzyMatcher;
4use fuzzy_matcher::skim::SkimMatcherV2;
5use inquire::Select;
6use serde_json;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::fmt;
10use std::path::Path;
11use std::process::Command;
12
13use crate::config::{RobinConfig, script_command, script_description};
14use crate::utils::send_notification;
15
16/// Expands a task's command, resolving any `@task` references into the commands
17/// of the referenced task (recursively), and returns a flattened command ready
18/// to run. Reference cycles are detected and reported as errors.
19///
20/// A bare string command with no reference is returned unchanged so simple
21/// tasks keep their single-command behaviour (including notifications). Anything
22/// that contains a reference, or is an array, flattens to an array of shell
23/// commands. Variable substitution is applied later, by the caller.
24pub fn resolve_task_command(cmd: &Value, scripts: &HashMap<String, Value>) -> Result<Value> {
25    // Fast path: a plain command with no reference needs no expansion.
26    if let Value::String(s) = cmd {
27        if !s.trim_start().starts_with('@') {
28            return Ok(cmd.clone());
29        }
30    }
31
32    let mut out = Vec::new();
33    let mut stack = Vec::new();
34    resolve_into(cmd, scripts, &mut stack, &mut out)?;
35    Ok(Value::Array(out.into_iter().map(Value::String).collect()))
36}
37
38/// Flattens a resolved command into the individual shell command lines it will
39/// run: a single string yields one line, an array yields one line per element.
40/// Used by `--dry-run` to preview exactly what would execute.
41pub fn command_lines(script: &Value) -> Vec<String> {
42    match script {
43        Value::String(s) => vec![s.clone()],
44        Value::Array(items) => items
45            .iter()
46            .filter_map(|c| c.as_str().map(str::to_string))
47            .collect(),
48        _ => Vec::new(),
49    }
50}
51
52fn resolve_into(
53    cmd: &Value,
54    scripts: &HashMap<String, Value>,
55    stack: &mut Vec<String>,
56    out: &mut Vec<String>,
57) -> Result<()> {
58    match cmd {
59        Value::String(s) => resolve_command_str(s, scripts, stack, out),
60        Value::Array(items) => {
61            for item in items {
62                if let Some(s) = item.as_str() {
63                    resolve_command_str(s, scripts, stack, out)?;
64                }
65            }
66            Ok(())
67        }
68        _ => Err(anyhow!(
69            "Invalid script type: must be string or array of strings"
70        )),
71    }
72}
73
74fn resolve_command_str(
75    s: &str,
76    scripts: &HashMap<String, Value>,
77    stack: &mut Vec<String>,
78    out: &mut Vec<String>,
79) -> Result<()> {
80    match s.trim_start().strip_prefix('@') {
81        Some(reference) => {
82            let name = reference.trim();
83            if stack.iter().any(|n| n == name) {
84                stack.push(name.to_string());
85                return Err(anyhow!(
86                    "Cycle detected in task references: {}",
87                    stack.join(" -> ")
88                ));
89            }
90            let entry = scripts
91                .get(name)
92                .ok_or_else(|| anyhow!("Referenced task '{}' not found", name))?;
93            let referenced = script_command(entry).ok_or_else(|| {
94                anyhow!("Referenced task '{}' has an invalid script definition", name)
95            })?;
96
97            stack.push(name.to_string());
98            resolve_into(referenced, scripts, stack, out)?;
99            stack.pop();
100            Ok(())
101        }
102        None => {
103            out.push(s.to_string());
104            Ok(())
105        }
106    }
107}
108
109/// Builds the shell command used to run a single script line, optionally in a
110/// specific working directory.
111fn shell_command(cmd: &str, cwd: Option<&Path>) -> Command {
112    let mut command = if cfg!(target_os = "windows") {
113        let mut c = Command::new("cmd");
114        c.args(["/C", cmd]);
115        c
116    } else {
117        let mut c = Command::new("sh");
118        c.arg("-c").arg(cmd);
119        c
120    };
121    if let Some(dir) = cwd {
122        command.current_dir(dir);
123    }
124    command
125}
126
127pub fn run_script(script: &serde_json::Value, notify: bool) -> Result<()> {
128    run_script_in(script, notify, None)
129}
130
131/// Runs a script, executing each command in `cwd` when provided (otherwise in
132/// the process's current directory).
133pub fn run_script_in(script: &serde_json::Value, notify: bool, cwd: Option<&Path>) -> Result<()> {
134    let start_time = std::time::Instant::now();
135
136    match script {
137        serde_json::Value::String(cmd) => {
138            let status = shell_command(cmd, cwd)
139                .status()
140                .with_context(|| format!("Failed to execute script: {}", cmd))?;
141
142            if notify {
143                let duration = start_time.elapsed();
144                let success = status.success();
145                let message = if success {
146                    format!("Completed in {:.1}s", duration.as_secs_f32())
147                } else {
148                    "Failed".to_string()
149                };
150
151                send_notification(
152                    "Robin",
153                    &format!(
154                        "Command '{}' {}",
155                        cmd.split_whitespace().next().unwrap_or(cmd),
156                        message
157                    ),
158                    success,
159                )?;
160            }
161
162            if !status.success() {
163                println!("{}", "Script failed!".red());
164                return Err(anyhow!("Script failed: {}", cmd));
165            }
166            Ok(())
167        }
168        serde_json::Value::Array(commands) => {
169            for cmd in commands {
170                if let Some(cmd_str) = cmd.as_str() {
171                    // Echo each step so the user can follow a multi-command
172                    // sequence and see exactly which command is running.
173                    println!("{} {}", "▶".cyan().bold(), cmd_str);
174
175                    let status = shell_command(cmd_str, cwd)
176                        .status()
177                        .with_context(|| format!("Failed to execute script: {}", cmd_str))?;
178
179                    if !status.success() {
180                        println!("{}", format!("Script failed: {}", cmd_str).red());
181                        return Err(anyhow!("Script failed: {}", cmd_str));
182                    }
183                }
184            }
185
186            if notify {
187                let duration = start_time.elapsed();
188                send_notification(
189                    "Robin",
190                    &format!(
191                        "Command sequence completed in {:.1}s",
192                        duration.as_secs_f32()
193                    ),
194                    true,
195                )?;
196            }
197            Ok(())
198        }
199        _ => Err(anyhow!(
200            "Invalid script type: must be string or array of strings"
201        )),
202    }
203}
204
205pub fn list_commands(config_path: &Path) -> Result<()> {
206    let config = RobinConfig::load(config_path)
207        .with_context(|| "No .robin.json found. Run 'robin init' first")?;
208
209    // Find the longest command name for padding
210    let max_len = config
211        .scripts
212        .keys()
213        .map(|name| name.len())
214        .max()
215        .unwrap_or(0);
216
217    // Convert to sorted vec for alphabetical ordering
218    let mut commands: Vec<_> = config.scripts.iter().collect();
219    commands.sort_by(|a, b| a.0.cmp(b.0));
220
221    for (name, script) in commands {
222        if let Some(desc) = script_description(script) {
223            println!("    {:<width$}   {}", "", desc.dimmed(), width = max_len);
224        }
225        match script_command(script) {
226            Some(serde_json::Value::String(cmd)) => {
227                println!("==> {:<width$} # {}", name.blue(), cmd, width = max_len);
228            }
229            Some(serde_json::Value::Array(commands)) => {
230                println!("==> {:<width$} # [", name.blue(), width = max_len);
231                for cmd in commands {
232                    if let Some(cmd_str) = cmd.as_str() {
233                        println!("       {}", cmd_str);
234                    }
235                }
236                println!("     ]");
237            }
238            _ => println!(
239                "==> {:<width$} # <invalid script type>",
240                name.blue(),
241                width = max_len
242            ),
243        }
244    }
245
246    Ok(())
247}
248
249/// One selectable row in the interactive picker.
250struct CommandChoice {
251    /// The task name, used to look the script back up after selection.
252    name: String,
253    /// Plain-text `name description` used for fuzzy matching (never colored,
254    /// so ANSI codes in `label` don't pollute the search).
255    search: String,
256    /// Pre-rendered, column-aligned label shown in the picker.
257    label: String,
258}
259
260impl fmt::Display for CommandChoice {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        write!(f, "{}", self.label)
263    }
264}
265
266/// Builds the aligned, sorted list of picker rows from the config's scripts.
267/// Names are sorted alphabetically (HashMap order isn't stable) and padded to a
268/// common width so descriptions line up in one column.
269fn command_choices(scripts: &HashMap<String, Value>) -> Vec<CommandChoice> {
270    let mut entries: Vec<(&String, &Value)> = scripts.iter().collect();
271    entries.sort_by(|a, b| a.0.cmp(b.0));
272
273    let max_len = entries.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
274
275    entries
276        .into_iter()
277        .map(|(name, script)| match script_description(script) {
278            Some(desc) => CommandChoice {
279                name: name.clone(),
280                search: format!("{} {}", name, desc),
281                label: format!("{:<width$}   {}", name, desc.dimmed(), width = max_len),
282            },
283            None => CommandChoice {
284                name: name.clone(),
285                search: name.clone(),
286                label: name.clone(),
287            },
288        })
289        .collect()
290}
291
292pub fn interactive_mode(config_path: &Path) -> Result<()> {
293    let config = RobinConfig::load(config_path)
294        .with_context(|| "No .robin.json found. Run 'robin init' first")?;
295
296    if config.scripts.is_empty() {
297        println!("{}", "No commands available".red());
298        return Ok(());
299    }
300
301    let choices = command_choices(&config.scripts);
302
303    // Fuzzy-match against the plain `search` text (name + description) rather
304    // than the colored label inquire would use by default.
305    let matcher = SkimMatcherV2::default();
306    let scorer = |input: &str, choice: &CommandChoice, _: &str, _: usize| -> Option<i64> {
307        if input.is_empty() {
308            return Some(0);
309        }
310        matcher.fuzzy_match(&choice.search, input)
311    };
312
313    let selection = Select::new("Select a command to run:", choices)
314        .with_scorer(&scorer)
315        .prompt()?;
316
317    if let Some(script) = config.scripts.get(&selection.name) {
318        if let Some(cmd) = script_command(script) {
319            let resolved = resolve_task_command(cmd, &config.scripts)?;
320            run_script(&resolved, false)?;
321        }
322    }
323
324    Ok(())
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use serde_json::json;
331
332    fn strip_ansi(s: &str) -> String {
333        let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
334        re.replace_all(s, "").to_string()
335    }
336
337    #[test]
338    fn choices_are_sorted_and_description_column_is_aligned() {
339        let mut scripts = HashMap::new();
340        scripts.insert("build".to_string(), json!({ "cmd": "cargo build", "desc": "Compile" }));
341        scripts.insert("deploy".to_string(), json!({ "cmd": "ship", "desc": "Release it" }));
342        scripts.insert("clean".to_string(), json!("rm -rf target/"));
343
344        let choices = command_choices(&scripts);
345
346        // Alphabetical order, independent of HashMap iteration order.
347        assert_eq!(
348            choices.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
349            vec!["build", "clean", "deploy"]
350        );
351
352        // The description column starts at the same offset for every row that
353        // has a description (name padded to the longest name, "deploy" = 6).
354        let build = strip_ansi(&choices[0].label);
355        let deploy = strip_ansi(&choices[2].label);
356        assert_eq!(build.find("Compile"), deploy.find("Release it"));
357        assert_eq!(build, "build    Compile");
358        assert_eq!(deploy, "deploy   Release it");
359
360        // A task without a description renders as just its (unpadded) name.
361        assert_eq!(choices[1].label, "clean");
362    }
363
364    #[test]
365    fn search_text_is_plain_name_plus_description() {
366        let mut scripts = HashMap::new();
367        scripts.insert("build".to_string(), json!({ "cmd": "x", "desc": "Compile the app" }));
368        scripts.insert("clean".to_string(), json!("rm -rf target/"));
369
370        let choices = command_choices(&scripts);
371
372        // Description is searchable, and the search text carries no ANSI codes.
373        let build = choices.iter().find(|c| c.name == "build").unwrap();
374        assert_eq!(build.search, "build Compile the app");
375        assert!(!build.search.contains('\u{1b}'));
376
377        // No description -> search is just the name.
378        let clean = choices.iter().find(|c| c.name == "clean").unwrap();
379        assert_eq!(clean.search, "clean");
380    }
381}