use anyhow::{Context, Result, anyhow};
use colored::*;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use inquire::Select;
use serde_json;
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use std::process::Command;
use crate::config::{RobinConfig, script_command, script_description};
use crate::utils::send_notification;
pub fn resolve_task_command(cmd: &Value, scripts: &HashMap<String, Value>) -> Result<Value> {
if let Value::String(s) = cmd {
if !s.trim_start().starts_with('@') {
return Ok(cmd.clone());
}
}
let mut out = Vec::new();
let mut stack = Vec::new();
resolve_into(cmd, scripts, &mut stack, &mut out)?;
Ok(Value::Array(out.into_iter().map(Value::String).collect()))
}
pub fn command_lines(script: &Value) -> Vec<String> {
match script {
Value::String(s) => vec![s.clone()],
Value::Array(items) => items
.iter()
.filter_map(|c| c.as_str().map(str::to_string))
.collect(),
_ => Vec::new(),
}
}
fn resolve_into(
cmd: &Value,
scripts: &HashMap<String, Value>,
stack: &mut Vec<String>,
out: &mut Vec<String>,
) -> Result<()> {
match cmd {
Value::String(s) => resolve_command_str(s, scripts, stack, out),
Value::Array(items) => {
for item in items {
if let Some(s) = item.as_str() {
resolve_command_str(s, scripts, stack, out)?;
}
}
Ok(())
}
_ => Err(anyhow!(
"Invalid script type: must be string or array of strings"
)),
}
}
fn resolve_command_str(
s: &str,
scripts: &HashMap<String, Value>,
stack: &mut Vec<String>,
out: &mut Vec<String>,
) -> Result<()> {
match s.trim_start().strip_prefix('@') {
Some(reference) => {
let name = reference.trim();
if stack.iter().any(|n| n == name) {
stack.push(name.to_string());
return Err(anyhow!(
"Cycle detected in task references: {}",
stack.join(" -> ")
));
}
let entry = scripts
.get(name)
.ok_or_else(|| anyhow!("Referenced task '{}' not found", name))?;
let referenced = script_command(entry).ok_or_else(|| {
anyhow!("Referenced task '{}' has an invalid script definition", name)
})?;
stack.push(name.to_string());
resolve_into(referenced, scripts, stack, out)?;
stack.pop();
Ok(())
}
None => {
out.push(s.to_string());
Ok(())
}
}
}
fn shell_command(cmd: &str, cwd: Option<&Path>) -> Command {
let mut command = if cfg!(target_os = "windows") {
let mut c = Command::new("cmd");
c.args(["/C", cmd]);
c
} else {
let mut c = Command::new("sh");
c.arg("-c").arg(cmd);
c
};
if let Some(dir) = cwd {
command.current_dir(dir);
}
command
}
pub fn run_script(script: &serde_json::Value, notify: bool) -> Result<()> {
run_script_in(script, notify, None)
}
pub fn run_script_in(script: &serde_json::Value, notify: bool, cwd: Option<&Path>) -> Result<()> {
let start_time = std::time::Instant::now();
match script {
serde_json::Value::String(cmd) => {
let status = shell_command(cmd, cwd)
.status()
.with_context(|| format!("Failed to execute script: {}", cmd))?;
if notify {
let duration = start_time.elapsed();
let success = status.success();
let message = if success {
format!("Completed in {:.1}s", duration.as_secs_f32())
} else {
"Failed".to_string()
};
send_notification(
"Robin",
&format!(
"Command '{}' {}",
cmd.split_whitespace().next().unwrap_or(cmd),
message
),
success,
)?;
}
if !status.success() {
println!("{}", "Script failed!".red());
return Err(anyhow!("Script failed: {}", cmd));
}
Ok(())
}
serde_json::Value::Array(commands) => {
for cmd in commands {
if let Some(cmd_str) = cmd.as_str() {
println!("{} {}", "â–¶".cyan().bold(), cmd_str);
let status = shell_command(cmd_str, cwd)
.status()
.with_context(|| format!("Failed to execute script: {}", cmd_str))?;
if !status.success() {
println!("{}", format!("Script failed: {}", cmd_str).red());
return Err(anyhow!("Script failed: {}", cmd_str));
}
}
}
if notify {
let duration = start_time.elapsed();
send_notification(
"Robin",
&format!(
"Command sequence completed in {:.1}s",
duration.as_secs_f32()
),
true,
)?;
}
Ok(())
}
_ => Err(anyhow!(
"Invalid script type: must be string or array of strings"
)),
}
}
pub fn list_commands(config_path: &Path) -> Result<()> {
let config = RobinConfig::load(config_path)
.with_context(|| "No .robin.json found. Run 'robin init' first")?;
let max_len = config
.scripts
.keys()
.map(|name| name.len())
.max()
.unwrap_or(0);
let mut commands: Vec<_> = config.scripts.iter().collect();
commands.sort_by(|a, b| a.0.cmp(b.0));
for (name, script) in commands {
if let Some(desc) = script_description(script) {
println!(" {:<width$} {}", "", desc.dimmed(), width = max_len);
}
match script_command(script) {
Some(serde_json::Value::String(cmd)) => {
println!("==> {:<width$} # {}", name.blue(), cmd, width = max_len);
}
Some(serde_json::Value::Array(commands)) => {
println!("==> {:<width$} # [", name.blue(), width = max_len);
for cmd in commands {
if let Some(cmd_str) = cmd.as_str() {
println!(" {}", cmd_str);
}
}
println!(" ]");
}
_ => println!(
"==> {:<width$} # <invalid script type>",
name.blue(),
width = max_len
),
}
}
Ok(())
}
struct CommandChoice {
name: String,
search: String,
label: String,
}
impl fmt::Display for CommandChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.label)
}
}
fn command_choices(scripts: &HashMap<String, Value>) -> Vec<CommandChoice> {
let mut entries: Vec<(&String, &Value)> = scripts.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let max_len = entries.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
entries
.into_iter()
.map(|(name, script)| match script_description(script) {
Some(desc) => CommandChoice {
name: name.clone(),
search: format!("{} {}", name, desc),
label: format!("{:<width$} {}", name, desc.dimmed(), width = max_len),
},
None => CommandChoice {
name: name.clone(),
search: name.clone(),
label: name.clone(),
},
})
.collect()
}
pub fn interactive_mode(config_path: &Path) -> Result<()> {
let config = RobinConfig::load(config_path)
.with_context(|| "No .robin.json found. Run 'robin init' first")?;
if config.scripts.is_empty() {
println!("{}", "No commands available".red());
return Ok(());
}
let choices = command_choices(&config.scripts);
let matcher = SkimMatcherV2::default();
let scorer = |input: &str, choice: &CommandChoice, _: &str, _: usize| -> Option<i64> {
if input.is_empty() {
return Some(0);
}
matcher.fuzzy_match(&choice.search, input)
};
let selection = Select::new("Select a command to run:", choices)
.with_scorer(&scorer)
.prompt()?;
if let Some(script) = config.scripts.get(&selection.name) {
if let Some(cmd) = script_command(script) {
let resolved = resolve_task_command(cmd, &config.scripts)?;
run_script(&resolved, false)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn strip_ansi(s: &str) -> String {
let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
re.replace_all(s, "").to_string()
}
#[test]
fn choices_are_sorted_and_description_column_is_aligned() {
let mut scripts = HashMap::new();
scripts.insert("build".to_string(), json!({ "cmd": "cargo build", "desc": "Compile" }));
scripts.insert("deploy".to_string(), json!({ "cmd": "ship", "desc": "Release it" }));
scripts.insert("clean".to_string(), json!("rm -rf target/"));
let choices = command_choices(&scripts);
assert_eq!(
choices.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
vec!["build", "clean", "deploy"]
);
let build = strip_ansi(&choices[0].label);
let deploy = strip_ansi(&choices[2].label);
assert_eq!(build.find("Compile"), deploy.find("Release it"));
assert_eq!(build, "build Compile");
assert_eq!(deploy, "deploy Release it");
assert_eq!(choices[1].label, "clean");
}
#[test]
fn search_text_is_plain_name_plus_description() {
let mut scripts = HashMap::new();
scripts.insert("build".to_string(), json!({ "cmd": "x", "desc": "Compile the app" }));
scripts.insert("clean".to_string(), json!("rm -rf target/"));
let choices = command_choices(&scripts);
let build = choices.iter().find(|c| c.name == "build").unwrap();
assert_eq!(build.search, "build Compile the app");
assert!(!build.search.contains('\u{1b}'));
let clean = choices.iter().find(|c| c.name == "clean").unwrap();
assert_eq!(clean.search, "clean");
}
}