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
16pub fn resolve_task_command(cmd: &Value, scripts: &HashMap<String, Value>) -> Result<Value> {
25 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
38pub 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
109fn 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
131pub 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 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 let max_len = config
211 .scripts
212 .keys()
213 .map(|name| name.len())
214 .max()
215 .unwrap_or(0);
216
217 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
249struct CommandChoice {
251 name: String,
253 search: String,
256 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
266fn 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 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 assert_eq!(
348 choices.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
349 vec!["build", "clean", "deploy"]
350 );
351
352 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 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 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 let clean = choices.iter().find(|c| c.name == "clean").unwrap();
379 assert_eq!(clean.search, "clean");
380 }
381}