use crate::config::Config;
use crate::docs::DocsFetcher;
use crate::error::{OxoError, Result};
#[cfg(not(target_arch = "wasm32"))]
use crate::history::{CommandProvenance, HistoryEntry, HistoryStore};
#[cfg(not(target_arch = "wasm32"))]
use crate::job;
use crate::llm::{LlmClient, LlmCommandSuggestion};
use crate::skill::SkillManager;
#[cfg(not(target_arch = "wasm32"))]
use chrono::Utc;
use colored::Colorize;
#[cfg(not(target_arch = "wasm32"))]
use indicatif::{ProgressBar, ProgressStyle};
#[cfg(not(target_arch = "wasm32"))]
use std::collections::HashMap;
#[cfg(not(target_arch = "wasm32"))]
use std::process::Command;
#[cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
#[cfg(not(target_arch = "wasm32"))]
use uuid::Uuid;
pub struct GeneratedCommand {
pub full_cmd: String,
pub explanation: String,
pub effective_task: String,
}
struct PrepareResult {
suggestion: LlmCommandSuggestion,
docs_hash: String,
skill_name: Option<String>,
effective_task: String,
}
pub struct Runner {
config: Config,
fetcher: DocsFetcher,
llm: LlmClient,
skill_manager: SkillManager,
verbose: bool,
no_cache: bool,
verify: bool,
optimize_task: bool,
no_skill: bool,
no_doc: bool,
no_prompt: bool,
#[cfg(not(target_arch = "wasm32"))]
vars: HashMap<String, String>,
#[cfg(not(target_arch = "wasm32"))]
input_items: Vec<String>,
#[cfg(not(target_arch = "wasm32"))]
jobs: usize,
#[cfg(not(target_arch = "wasm32"))]
stop_on_error: bool,
}
impl Runner {
pub fn new(config: Config) -> Self {
Runner {
fetcher: DocsFetcher::new(config.clone()),
llm: LlmClient::new(config.clone()),
skill_manager: SkillManager::new(config.clone()),
config,
verbose: false,
no_cache: false,
verify: false,
optimize_task: false,
no_skill: false,
no_doc: false,
no_prompt: false,
#[cfg(not(target_arch = "wasm32"))]
vars: HashMap::new(),
#[cfg(not(target_arch = "wasm32"))]
input_items: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
jobs: 1,
#[cfg(not(target_arch = "wasm32"))]
stop_on_error: false,
}
}
pub fn with_verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn with_no_cache(mut self, no_cache: bool) -> Self {
self.no_cache = no_cache;
self
}
pub fn with_verify(mut self, verify: bool) -> Self {
self.verify = verify;
self
}
pub fn with_optimize_task(mut self, optimize_task: bool) -> Self {
self.optimize_task = optimize_task;
self
}
pub fn with_no_skill(mut self, no_skill: bool) -> Self {
self.no_skill = no_skill;
self
}
pub fn with_no_doc(mut self, no_doc: bool) -> Self {
self.no_doc = no_doc;
self
}
pub fn with_no_prompt(mut self, no_prompt: bool) -> Self {
self.no_prompt = no_prompt;
self
}
#[cfg(not(target_arch = "wasm32"))]
pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
self.vars = vars;
self
}
#[cfg(not(target_arch = "wasm32"))]
pub fn with_input_items(mut self, items: Vec<String>) -> Self {
self.input_items = items;
self
}
#[cfg(not(target_arch = "wasm32"))]
pub fn with_jobs(mut self, jobs: usize) -> Self {
self.jobs = jobs.max(1);
self
}
#[cfg(not(target_arch = "wasm32"))]
pub fn with_stop_on_error(mut self, stop_on_error: bool) -> Self {
self.stop_on_error = stop_on_error;
self
}
async fn resolve_docs(&self, tool: &str) -> Result<String> {
let docs = if self.no_cache {
self.fetcher.fetch_no_cache(tool).await?
} else {
self.fetcher.fetch(tool).await?
};
Ok(docs.combined())
}
async fn prepare(&self, tool: &str, task: &str) -> Result<PrepareResult> {
let docs = if self.no_doc {
if self.verbose {
eprintln!(
"{} [Ablation] Skipping documentation (--no-doc)",
"[verbose]".dimmed()
);
}
String::new()
} else {
let spinner = make_spinner(&format!("Fetching documentation for '{tool}'..."));
match self.resolve_docs(tool).await {
Ok(d) => {
spinner.finish_and_clear();
d
}
Err(e) => {
spinner.finish_and_clear();
return Err(e);
}
}
};
if self.verbose && !self.no_doc {
eprintln!(
"{} Documentation: {} chars{}",
"[verbose]".dimmed(),
docs.len(),
if self.no_cache {
" (fresh, cache skipped)"
} else {
""
}
);
}
let docs_hash = sha256_hex(&docs);
let effective_task = if self.optimize_task {
let spinner = make_spinner("Optimizing task description...");
let refined = match self.llm.optimize_task(tool, task).await {
Ok(t) => {
spinner.finish_and_clear();
t
}
Err(e) => {
spinner.finish_and_clear();
if self.verbose {
eprintln!(
"{} Task optimization failed ({}), using original task",
"[verbose]".dimmed(),
e
);
}
task.to_string()
}
};
if self.verbose && refined != task {
eprintln!(
"{} Task optimized: {}",
"[verbose]".dimmed(),
refined.dimmed()
);
}
refined
} else {
task.to_string()
};
let skill = if self.no_skill {
if self.verbose {
eprintln!(
"{} [Ablation] Skipping skill (--no-skill)",
"[verbose]".dimmed()
);
}
None
} else {
#[cfg(not(target_arch = "wasm32"))]
{
self.skill_manager.load_async(tool).await
}
#[cfg(target_arch = "wasm32")]
{
self.skill_manager.load(tool)
}
};
let skill_name = skill.as_ref().map(|s| s.meta.name.clone());
let skill_label = if skill.is_some() {
format!(" (skill: {})", tool)
} else {
String::new()
};
if self.verbose {
if let Some(ref s) = skill {
eprintln!(
"{} Skill loaded: {} ({} concepts, {} pitfalls, {} examples)",
"[verbose]".dimmed(),
s.meta.name,
s.context.concepts.len(),
s.context.pitfalls.len(),
s.examples.len()
);
} else if !self.no_skill {
eprintln!("{} No skill found for '{}'", "[verbose]".dimmed(), tool);
}
eprintln!(
"{} LLM: provider={}, model={}, max_tokens={}, temperature={}",
"[verbose]".dimmed(),
self.config.effective_provider(),
self.config.effective_model(),
self.config.effective_max_tokens().unwrap_or(2048),
self.config.effective_temperature().unwrap_or(0.0)
);
}
let spinner = make_spinner(&format!(
"Asking LLM to generate command arguments{skill_label}..."
));
let suggestion = match self
.llm
.suggest_command(tool, &docs, &effective_task, skill.as_ref(), self.no_prompt)
.await
{
Ok(s) => {
spinner.finish_and_clear();
s
}
Err(e) => {
spinner.finish_and_clear();
return Err(e);
}
};
Ok(PrepareResult {
suggestion,
docs_hash,
skill_name,
effective_task,
})
}
pub async fn generate_command(&self, tool: &str, task: &str) -> Result<GeneratedCommand> {
let result = self.prepare(tool, task).await?;
let full_cmd = build_command_string(tool, &result.suggestion.args);
Ok(GeneratedCommand {
full_cmd,
explanation: result.suggestion.explanation.clone(),
effective_task: result.effective_task.clone(),
})
}
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
pub async fn dry_run(
&self,
tool: &str,
task: &str,
json: bool,
server: Option<&str>,
) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
let _task_buf;
#[cfg(not(target_arch = "wasm32"))]
let task: &str = if self.vars.is_empty() {
task
} else {
_task_buf = job::interpolate_command(task, "", 0, &self.vars);
&_task_buf
};
#[cfg(not(target_arch = "wasm32"))]
if !self.input_items.is_empty() {
return self.dry_run_batch(tool, task, json).await;
}
let result = self.prepare(tool, task).await?;
let full_cmd = build_command_string(tool, &result.suggestion.args);
#[cfg(not(target_arch = "wasm32"))]
{
let tool_version = detect_tool_version(tool);
let entry = HistoryEntry {
id: Uuid::new_v4().to_string(),
tool: tool.to_string(),
task: task.to_string(),
command: full_cmd.clone(),
exit_code: 0,
executed_at: Utc::now(),
dry_run: true,
server: server.map(str::to_string),
provenance: Some(CommandProvenance {
tool_version,
docs_hash: Some(result.docs_hash.clone()),
skill_name: result.skill_name.clone(),
model: Some(self.config.effective_model()),
}),
};
let _ = HistoryStore::append(entry);
}
if json {
let output = serde_json::json!({
"tool": tool,
"task": task,
"effective_task": result.effective_task,
"command": full_cmd,
"args": result.suggestion.args,
"explanation": result.suggestion.explanation,
"dry_run": true,
"skill": result.skill_name,
"model": self.config.effective_model(),
});
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
println!();
println!("{}", "─".repeat(60).dimmed());
println!(" {} {}", "Tool:".bold(), tool.cyan());
println!(" {} {}", "Task:".bold(), task);
if result.effective_task != task {
println!(
" {} {}",
"Optimized task:".bold().dimmed(),
result.effective_task.dimmed()
);
}
println!("{}", "─".repeat(60).dimmed());
println!();
println!(" {}", "Command (dry-run):".bold().yellow());
println!(" {}", full_cmd.green().bold());
println!();
if !result.suggestion.explanation.is_empty() {
println!(" {}", "Explanation:".bold());
println!(" {}", result.suggestion.explanation);
println!();
}
println!("{}", "─".repeat(60).dimmed());
println!(
" {}",
"Use 'oxo-call run' to execute this command.".dimmed()
);
Ok(())
}
pub async fn run(&self, tool: &str, task: &str, ask: bool, json: bool) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
let _task_buf;
#[cfg(not(target_arch = "wasm32"))]
let task: &str = if self.vars.is_empty() {
task
} else {
_task_buf = job::interpolate_command(task, "", 0, &self.vars);
&_task_buf
};
#[cfg(not(target_arch = "wasm32"))]
if !self.input_items.is_empty() {
return self.run_batch(tool, task, json).await;
}
let result = self.prepare(tool, task).await?;
let full_cmd = build_command_string(tool, &result.suggestion.args);
if !json {
println!();
println!("{}", "─".repeat(60).dimmed());
println!(" {} {}", "Tool:".bold(), tool.cyan());
println!(" {} {}", "Task:".bold(), task);
if result.effective_task != task {
println!(
" {} {}",
"Optimized task:".bold().dimmed(),
result.effective_task.dimmed()
);
}
println!("{}", "─".repeat(60).dimmed());
println!();
println!(" {}", "Generated command:".bold().green());
println!(" {}", full_cmd.green().bold());
println!();
if !result.suggestion.explanation.is_empty() {
println!(" {}", "Explanation:".bold());
println!(" {}", result.suggestion.explanation);
println!();
}
}
if ask {
print!(" {} [y/N] ", "Execute this command?".bold().yellow());
use std::io::{self, Write};
io::stdout().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
let input = input.trim().to_lowercase();
if input != "y" && input != "yes" {
println!("{}", "Aborted.".red());
return Ok(());
}
}
if !json {
println!();
println!("{}", "─".repeat(60).dimmed());
println!(" {} {}", "Running:".bold(), full_cmd.cyan());
println!("{}", "─".repeat(60).dimmed());
println!();
}
#[cfg(target_arch = "wasm32")]
return Err(OxoError::ExecutionError(
"Command execution is not supported in WebAssembly".to_string(),
));
#[cfg(not(target_arch = "wasm32"))]
{
let (eff_tool, eff_args) = effective_command(tool, &result.suggestion.args);
let use_shell = args_require_shell(&result.suggestion.args);
let (exit_code, success, captured_stderr) = if self.verify {
let output = if use_shell {
Command::new("sh")
.args(["-c", &full_cmd])
.output()
.map_err(|e| OxoError::ExecutionError(format!("sh: {e}")))?
} else {
Command::new(eff_tool)
.args(eff_args)
.output()
.map_err(|e| OxoError::ToolNotFound(format!("{eff_tool}: {e}")))?
};
use std::io::Write;
let _ = std::io::stdout().write_all(&output.stdout);
let _ = std::io::stderr().write_all(&output.stderr);
let code = output.status.code().unwrap_or(-1);
let ok = output.status.success();
let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned();
(code, ok, stderr_text)
} else {
let status = if use_shell {
Command::new("sh")
.args(["-c", &full_cmd])
.status()
.map_err(|e| OxoError::ExecutionError(format!("sh: {e}")))?
} else {
Command::new(eff_tool)
.args(eff_args)
.status()
.map_err(|e| OxoError::ToolNotFound(format!("{eff_tool}: {e}")))?
};
let code = status.code().unwrap_or(-1);
let ok = status.success();
(code, ok, String::new())
};
let tool_version = detect_tool_version(eff_tool);
let entry = HistoryEntry {
id: Uuid::new_v4().to_string(),
tool: tool.to_string(),
task: task.to_string(),
command: full_cmd.clone(),
exit_code,
executed_at: Utc::now(),
dry_run: false,
server: None,
provenance: Some(CommandProvenance {
tool_version,
docs_hash: Some(result.docs_hash),
skill_name: result.skill_name.clone(),
model: Some(self.config.effective_model()),
}),
};
let _ = HistoryStore::append(entry);
if json {
let output = serde_json::json!({
"tool": tool,
"task": task,
"effective_task": result.effective_task,
"command": full_cmd,
"args": result.suggestion.args,
"explanation": result.suggestion.explanation,
"dry_run": false,
"exit_code": exit_code,
"success": success,
"skill": result.skill_name,
"model": self.config.effective_model(),
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!();
println!("{}", "─".repeat(60).dimmed());
if success {
println!(
" {} exit code {}",
"Completed successfully,".bold().green(),
exit_code.to_string().green()
);
} else {
println!(
" {} exit code {}",
"Command failed,".bold().red(),
exit_code.to_string().red()
);
}
println!("{}", "─".repeat(60).dimmed());
}
if self.verify {
self.run_verification(VerifyParams {
tool,
task: &result.effective_task,
command: &full_cmd,
exit_code,
stderr: &captured_stderr,
args: &result.suggestion.args,
json,
})
.await;
}
Ok(())
}
}
}
#[cfg(not(target_arch = "wasm32"))]
struct VerifyParams<'a> {
tool: &'a str,
task: &'a str,
command: &'a str,
exit_code: i32,
stderr: &'a str,
args: &'a [String],
json: bool,
}
impl Runner {
#[cfg(not(target_arch = "wasm32"))]
async fn run_verification(&self, params: VerifyParams<'_>) {
let VerifyParams {
tool,
task,
command,
exit_code,
stderr,
args,
json,
} = params;
let output_files = detect_output_files(args);
let file_info: Vec<(String, Option<u64>)> = output_files
.into_iter()
.map(|p| {
let size = std::fs::metadata(&p).ok().map(|m| m.len());
(p, size)
})
.collect();
let spinner = make_spinner("Verifying result with LLM...");
let verification = match self
.llm
.verify_run_result(tool, task, command, exit_code, stderr, &file_info)
.await
{
Ok(v) => {
spinner.finish_and_clear();
v
}
Err(e) => {
spinner.finish_and_clear();
eprintln!(
"{} LLM verification failed: {}",
"warning:".yellow().bold(),
e
);
return;
}
};
if json {
let v = serde_json::json!({
"verification": {
"success": verification.success,
"summary": verification.summary,
"issues": verification.issues,
"suggestions": verification.suggestions,
}
});
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
return;
}
println!();
println!("{}", "─".repeat(60).dimmed());
let label = if verification.success {
"LLM Verification: OK".bold().green().to_string()
} else {
"LLM Verification: Issues detected".bold().red().to_string()
};
println!(" {}", label);
if !verification.summary.is_empty() {
println!(" {}", verification.summary);
}
if !verification.issues.is_empty() {
println!();
println!(" {}", "Issues:".bold().yellow());
for issue in &verification.issues {
println!(" {} {}", "•".yellow(), issue);
}
}
if !verification.suggestions.is_empty() {
println!();
println!(" {}", "Suggestions:".bold().cyan());
for sug in &verification.suggestions {
println!(" {} {}", "→".cyan(), sug);
}
}
println!("{}", "─".repeat(60).dimmed());
}
}
impl Runner {
#[cfg(not(target_arch = "wasm32"))]
async fn run_batch(&self, tool: &str, task: &str, json: bool) -> Result<()> {
use std::sync::Arc;
let result = self.prepare(tool, task).await?;
let cmd_template = build_command_string(tool, &result.suggestion.args);
let docs_hash = result.docs_hash.clone();
let skill_name = result.skill_name.clone();
let items = &self.input_items;
let n = items.len();
let jobs = self.jobs.max(1);
if !json {
println!();
println!("{}", "─".repeat(60).dimmed());
println!(" {} {}", "Tool:".bold(), tool.cyan());
println!(" {} {}", "Task template:".bold(), task);
println!("{}", "─".repeat(60).dimmed());
println!();
println!(" {}", "Command template:".bold().green());
println!(" {}", cmd_template.green().bold());
println!();
if !result.suggestion.explanation.is_empty() {
println!(" {}", "Explanation:".bold());
println!(" {}", result.suggestion.explanation);
println!();
}
println!(
" {} {} items, {} parallel{}",
"Batch:".bold(),
n.to_string().cyan(),
jobs.to_string().cyan(),
if self.stop_on_error {
" (stop-on-error)".yellow().to_string()
} else {
String::new()
},
);
println!("{}", "─".repeat(60).dimmed());
println!();
}
let batch_started = Utc::now();
let sem = Arc::new(tokio::sync::Semaphore::new(jobs));
let mut handles: Vec<(String, tokio::task::JoinHandle<Result<i32>>)> =
Vec::with_capacity(n);
for (i, item) in items.iter().enumerate() {
let cmd = job::interpolate_command(&cmd_template, item, i + 1, &self.vars);
let sem_clone = Arc::clone(&sem);
let item_label = item.clone();
let handle: tokio::task::JoinHandle<Result<i32>> = tokio::spawn(async move {
let _permit = sem_clone
.acquire_owned()
.await
.expect("semaphore closed unexpectedly");
tokio::task::spawn_blocking(move || {
std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
.status()
.map(|s| s.code().unwrap_or(-1))
.map_err(|e| {
OxoError::ExecutionError(format!("failed to run '{item_label}': {e}"))
})
})
.await
.map_err(|e| OxoError::ExecutionError(format!("task join error: {e}")))?
});
handles.push((item.clone(), handle));
}
let mut failed = 0usize;
let mut done = 0usize;
let mut results: Vec<(String, i32)> = Vec::with_capacity(n);
for (item, handle) in handles {
let code = match handle.await {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
failed += 1;
if !json {
eprintln!(" {} {}: {}", "✗".red().bold(), item, e);
}
-1
}
Err(e) => {
failed += 1;
if !json {
eprintln!(" {} {}: join error: {}", "✗".red().bold(), item, e);
}
-1
}
};
if code != 0 && code != -1 {
failed += 1;
}
done += 1;
if !json {
match code {
0 => println!(" {} [{}/{}] {}", "✓".green().bold(), done, n, item),
-1 => {} c => eprintln!(
" {} [{}/{}] {} (exit {})",
"✗".red().bold(),
done,
n,
item,
c.to_string().red()
),
}
}
results.push((item, code));
if self.stop_on_error && failed > 0 {
if !json {
eprintln!(
" {} stop-on-error: aborting after first failure ({}/{} done)",
"⚡".yellow().bold(),
done,
n
);
}
break;
}
}
{
let tool_version = detect_tool_version(tool);
let history_entry = HistoryEntry {
id: Uuid::new_v4().to_string(),
tool: tool.to_string(),
task: task.to_string(),
command: format!("{cmd_template} # batch:{n} vars:{}", self.vars.len()),
exit_code: if failed == 0 { 0 } else { 1 },
executed_at: batch_started,
dry_run: false,
server: None,
provenance: Some(CommandProvenance {
tool_version,
docs_hash: Some(docs_hash),
skill_name,
model: Some(self.config.effective_model()),
}),
};
let _ = HistoryStore::append(history_entry);
}
if json {
let entries: Vec<serde_json::Value> = results
.iter()
.enumerate()
.map(|(i, (item, code))| {
let cmd = job::interpolate_command(&cmd_template, item, i + 1, &self.vars);
serde_json::json!({
"item": item,
"command": cmd,
"exit_code": code,
"success": *code == 0,
})
})
.collect();
let output = serde_json::json!({
"tool": tool,
"task_template": task,
"command_template": cmd_template,
"total": n,
"processed": done,
"failed": failed,
"success": failed == 0,
"stopped_early": self.stop_on_error && done < n,
"results": entries,
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!();
println!("{}", "─".repeat(60).dimmed());
if failed == 0 {
println!(
" {} All {} items completed successfully.",
"✓".green().bold(),
n.to_string().green()
);
} else {
eprintln!(
" {} {}/{} items failed.",
"✗".red().bold(),
failed.to_string().red(),
done
);
}
println!("{}", "─".repeat(60).dimmed());
}
if failed > 0 {
return Err(OxoError::ExecutionError(format!(
"{failed}/{done} batch items failed"
)));
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
async fn dry_run_batch(&self, tool: &str, task: &str, json: bool) -> Result<()> {
let result = self.prepare(tool, task).await?;
let cmd_template = build_command_string(tool, &result.suggestion.args);
let items = &self.input_items;
let n = items.len();
let commands: Vec<String> = items
.iter()
.enumerate()
.map(|(i, item)| job::interpolate_command(&cmd_template, item, i + 1, &self.vars))
.collect();
if json {
let output = serde_json::json!({
"tool": tool,
"task_template": task,
"command_template": cmd_template,
"commands": commands,
"dry_run": true,
"skill": result.skill_name,
"model": self.config.effective_model(),
});
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
println!();
println!("{}", "─".repeat(60).dimmed());
println!(" {} {}", "Tool:".bold(), tool.cyan());
println!(" {} {}", "Task template:".bold(), task);
println!("{}", "─".repeat(60).dimmed());
println!();
println!(" {}", "Command template (dry-run):".bold().yellow());
println!(" {}", cmd_template.green().bold());
println!();
if !result.suggestion.explanation.is_empty() {
println!(" {}", "Explanation:".bold());
println!(" {}", result.suggestion.explanation);
println!();
}
println!(
" {} {} items would be processed:",
"Batch:".bold(),
n.to_string().cyan()
);
println!("{}", "─".repeat(60).dimmed());
for (i, cmd) in commands.iter().enumerate() {
println!(" [{:>3}] {}", i + 1, cmd.as_str().green());
}
println!("{}", "─".repeat(60).dimmed());
println!(
" {}",
"Use 'oxo-call run' to execute these commands.".dimmed()
);
Ok(())
}
}
fn build_command_string(tool: &str, args: &[String]) -> String {
if args.is_empty() {
return tool.to_string();
}
let (eff_tool, eff_args) = effective_command(tool, args);
if eff_args.is_empty() {
return eff_tool.to_string();
}
let args_str: Vec<String> = eff_args
.iter()
.map(|a| {
if is_shell_operator(a) {
a.clone()
} else if needs_quoting(a) {
format!("'{}'", a.replace('\'', "'\\''"))
} else {
a.clone()
}
})
.collect();
format!("{eff_tool} {}", args_str.join(" "))
}
pub(crate) fn effective_command<'a>(tool: &'a str, args: &'a [String]) -> (&'a str, &'a [String]) {
if let Some(first) = args.first() {
if is_companion_binary(tool, first) {
return (first.as_str(), &args[1..]);
}
if is_script_executable(first) {
return (first.as_str(), &args[1..]);
}
}
(tool, args)
}
const SCRIPT_EXTENSIONS: &[&str] = &[".sh", ".py", ".pl", ".R", ".rb", ".jl"];
pub(crate) fn is_script_executable(candidate: &str) -> bool {
if candidate.contains('/') || candidate.contains('\\') {
return false;
}
if candidate.starts_with('-') {
return false;
}
for ext in SCRIPT_EXTENSIONS {
if let Some(stem) = candidate.strip_suffix(ext) {
return !stem.is_empty()
&& stem
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_');
}
}
false
}
pub(crate) fn is_companion_binary(tool: &str, candidate: &str) -> bool {
if candidate.starts_with('-') {
return false; }
const SCRIPT_EXTS: &[&str] = &[".sh", ".py", ".pl", ".R", ".rb", ".jl"];
let stem = SCRIPT_EXTS
.iter()
.find_map(|ext| candidate.strip_suffix(ext))
.unwrap_or(candidate);
if !stem
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return false;
}
if candidate != stem {
let stem_lower = stem.to_ascii_lowercase();
let tool_lower = tool.to_ascii_lowercase();
if stem_lower.contains(&tool_lower) {
return true;
}
}
let hyphen_prefix = format!("{tool}-");
let underscore_prefix = format!("{tool}_");
if stem.starts_with(&hyphen_prefix) || stem.starts_with(&underscore_prefix) {
return true;
}
let underscore_suffix = format!("_{tool}");
stem.ends_with(&underscore_suffix) && stem.len() > underscore_suffix.len()
}
fn is_shell_operator(arg: &str) -> bool {
matches!(
arg,
"&&" | "||" | ";" | ";;" | "|" | ">" | ">>" | "<" | "<<" | "2>" | "2>>"
)
}
fn args_require_shell(args: &[String]) -> bool {
args.iter().any(|a| is_shell_operator(a))
}
fn needs_quoting(arg: &str) -> bool {
arg.contains(' ')
|| arg.contains('\t')
|| arg.contains(';')
|| arg.contains('&')
|| arg.contains('|')
|| arg.contains('$')
|| arg.contains('`')
|| arg.contains('(')
|| arg.contains(')')
|| arg.contains('<')
|| arg.contains('>')
|| arg.contains('!')
|| arg.contains('\\')
|| arg.contains('"')
|| arg.contains('\'')
}
fn sha256_hex(input: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let result = hasher.finalize();
result.iter().fold(String::with_capacity(64), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
})
}
#[cfg(not(target_arch = "wasm32"))]
fn detect_tool_version(tool: &str) -> Option<String> {
Command::new(tool)
.arg("--version")
.output()
.ok()
.and_then(|out| {
let text = String::from_utf8_lossy(&out.stdout);
let line = text.lines().next().unwrap_or("").trim().to_string();
if line.is_empty() {
let stderr = String::from_utf8_lossy(&out.stderr);
let sline = stderr.lines().next().unwrap_or("").trim().to_string();
if sline.is_empty() { None } else { Some(sline) }
} else {
Some(line)
}
})
}
#[cfg(not(target_arch = "wasm32"))]
pub fn make_spinner(msg: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
pb.set_message(msg.to_string());
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
#[cfg(target_arch = "wasm32")]
pub struct Spinner;
#[cfg(target_arch = "wasm32")]
impl Spinner {
pub fn finish_and_clear(&self) {}
pub fn set_message(&self, _msg: String) {}
}
#[cfg(target_arch = "wasm32")]
pub fn make_spinner(_msg: &str) -> Spinner {
Spinner
}
#[cfg(not(target_arch = "wasm32"))]
fn detect_output_files(args: &[String]) -> Vec<String> {
const OUTPUT_FLAGS: &[&str] = &["-o", "--output", "-O", "--out", "-b", "--bam"];
let mut files: Vec<String> = Vec::new();
let mut take_next = false;
for arg in args {
if take_next {
files.push(arg.clone());
take_next = false;
continue;
}
let mut matched_flag = false;
for &flag in OUTPUT_FLAGS {
if arg.starts_with(&format!("{flag}=")) {
let value = &arg[flag.len() + 1..];
if !value.is_empty() {
files.push(value.to_string());
}
matched_flag = true;
break;
}
if arg == flag {
take_next = true;
matched_flag = true;
break;
}
}
if matched_flag {
continue;
}
if !arg.starts_with('-')
&& arg.contains('.')
&& !arg.contains(';')
&& !arg.contains('&')
&& !arg.contains('|')
{
files.push(arg.clone());
}
}
let mut seen = std::collections::HashSet::new();
files.retain(|f| seen.insert(f.clone()));
files.truncate(20);
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_output_files_short_flag() {
let args: Vec<String> = vec![
"-o".to_string(),
"out.bam".to_string(),
"input.bam".to_string(),
];
let files = detect_output_files(&args);
assert!(files.contains(&"out.bam".to_string()));
}
#[test]
fn test_detect_output_files_long_flag() {
let args: Vec<String> = vec!["--output".to_string(), "result.vcf".to_string()];
let files = detect_output_files(&args);
assert!(files.contains(&"result.vcf".to_string()));
}
#[test]
fn test_detect_output_files_equals_form() {
let args: Vec<String> = vec!["--output=sorted.bam".to_string()];
let files = detect_output_files(&args);
assert!(files.contains(&"sorted.bam".to_string()));
}
#[test]
fn test_detect_output_files_positional() {
let args: Vec<String> = vec![
"-t".to_string(),
"8".to_string(),
"input.fastq.gz".to_string(),
"output.fastq.gz".to_string(),
];
let files = detect_output_files(&args);
assert!(files.contains(&"input.fastq.gz".to_string()));
assert!(files.contains(&"output.fastq.gz".to_string()));
}
#[test]
fn test_detect_output_files_deduplicates() {
let args: Vec<String> = vec![
"-o".to_string(),
"out.bam".to_string(),
"out.bam".to_string(),
];
let files = detect_output_files(&args);
assert_eq!(files.iter().filter(|f| *f == "out.bam").count(), 1);
}
#[test]
fn test_detect_output_files_skips_flags() {
let args: Vec<String> = vec![
"--threads".to_string(),
"8".to_string(),
"--sort".to_string(),
];
let files = detect_output_files(&args);
assert!(!files.contains(&"--threads".to_string()));
assert!(!files.contains(&"--sort".to_string()));
}
#[test]
fn test_build_command_string_no_args() {
assert_eq!(build_command_string("echo", &[]), "echo");
}
#[test]
fn test_build_command_string_simple_args() {
let args: Vec<String> = vec!["-o".to_string(), "out.bam".to_string()];
let cmd = build_command_string("samtools", &args);
assert_eq!(cmd, "samtools -o out.bam");
}
#[test]
fn test_build_command_string_quotes_args_with_spaces() {
let args: Vec<String> = vec!["--output".to_string(), "my output file.bam".to_string()];
let cmd = build_command_string("samtools", &args);
assert!(
cmd.contains("'my output file.bam'"),
"args with spaces should be quoted"
);
}
#[test]
fn test_build_command_string_quotes_args_with_special_chars() {
let args: Vec<String> = vec!["--filter".to_string(), "flag & 0x4".to_string()];
let cmd = build_command_string("samtools", &args);
assert!(cmd.contains("'flag"), "args with & should be quoted");
}
#[test]
fn test_build_command_string_does_not_quote_shell_and_and() {
let args: Vec<String> = vec![
"sort".to_string(),
"-o".to_string(),
"sorted.bam".to_string(),
"input.bam".to_string(),
"&&".to_string(),
"samtools".to_string(),
"index".to_string(),
"sorted.bam".to_string(),
];
let cmd = build_command_string("samtools", &args);
assert!(cmd.contains(" && "), "cmd should contain unquoted &&");
assert!(!cmd.contains("'&&'"), "&& must not be single-quoted");
assert!(
cmd.contains("samtools index"),
"second subcommand must be present"
);
}
#[test]
fn test_build_command_string_does_not_quote_pipe() {
let args: Vec<String> = vec![
"view".to_string(),
"input.bam".to_string(),
"|".to_string(),
"grep".to_string(),
"^SQ".to_string(),
];
let cmd = build_command_string("samtools", &args);
assert!(cmd.contains(" | "), "cmd should contain unquoted |");
assert!(!cmd.contains("'|'"), "| must not be single-quoted");
}
#[test]
fn test_is_shell_operator_known_operators() {
assert!(is_shell_operator("&&"));
assert!(is_shell_operator("||"));
assert!(is_shell_operator(";"));
assert!(is_shell_operator("|"));
assert!(is_shell_operator(">"));
assert!(is_shell_operator(">>"));
assert!(is_shell_operator("<"));
assert!(is_shell_operator("2>"));
assert!(is_shell_operator("2>>"));
}
#[test]
fn test_is_shell_operator_rejects_non_operators() {
assert!(!is_shell_operator("-o"));
assert!(!is_shell_operator("out.bam"));
assert!(!is_shell_operator("&"));
assert!(!is_shell_operator("flag & 0x4"));
assert!(!is_shell_operator("samtools"));
assert!(!is_shell_operator(""));
}
#[test]
fn test_args_require_shell_with_double_ampersand() {
let args: Vec<String> = vec![
"sort".to_string(),
"-o".to_string(),
"sorted.bam".to_string(),
"&&".to_string(),
"samtools".to_string(),
"index".to_string(),
"sorted.bam".to_string(),
];
assert!(args_require_shell(&args));
}
#[test]
fn test_args_require_shell_with_pipe() {
let args: Vec<String> = vec!["view".to_string(), "|".to_string(), "grep".to_string()];
assert!(args_require_shell(&args));
}
#[test]
fn test_args_require_shell_without_operators() {
let args: Vec<String> = vec![
"sort".to_string(),
"-o".to_string(),
"out.bam".to_string(),
"input.bam".to_string(),
];
assert!(!args_require_shell(&args));
}
#[test]
fn test_args_require_shell_empty() {
assert!(!args_require_shell(&[]));
}
#[test]
fn test_needs_quoting_simple_arg_false() {
assert!(!needs_quoting("-o"));
assert!(!needs_quoting("out.bam"));
assert!(!needs_quoting("--threads=8"));
}
#[test]
fn test_needs_quoting_space_true() {
assert!(needs_quoting("my file.bam"));
}
#[test]
fn test_needs_quoting_special_chars_true() {
assert!(needs_quoting("a;b"));
assert!(needs_quoting("a&b"));
assert!(needs_quoting("a|b"));
assert!(needs_quoting("$HOME"));
assert!(needs_quoting("`cmd`"));
assert!(needs_quoting("(subshell)"));
assert!(needs_quoting("a<b"));
assert!(needs_quoting("a>b"));
assert!(needs_quoting("a!b"));
assert!(needs_quoting("a\\b"));
assert!(needs_quoting("a\"b"));
assert!(needs_quoting("a'b"));
}
#[test]
fn test_needs_quoting_tab_true() {
assert!(needs_quoting("a\tb"));
}
#[test]
fn test_sha256_hex_empty_string() {
let hash = sha256_hex("");
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn test_sha256_hex_hello_world() {
let hash = sha256_hex("hello world");
assert_eq!(hash.len(), 64, "SHA256 hex should be 64 characters");
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_sha256_hex_deterministic() {
let hash1 = sha256_hex("test input");
let hash2 = sha256_hex("test input");
assert_eq!(hash1, hash2, "SHA256 should be deterministic");
}
#[test]
fn test_sha256_hex_different_inputs_produce_different_hashes() {
let hash1 = sha256_hex("input one");
let hash2 = sha256_hex("input two");
assert_ne!(hash1, hash2);
}
#[test]
fn test_runner_new() {
use crate::config::Config;
let cfg = Config::default();
let runner = Runner::new(cfg);
let runner = runner.with_verbose(true);
let runner = runner.with_no_cache(true);
let runner = runner.with_verify(true);
let _runner = runner.with_optimize_task(true);
}
#[test]
fn test_detect_tool_version_existing_tool() {
let result = detect_tool_version("ls");
let _ = result;
}
#[test]
fn test_detect_tool_version_nonexistent_tool_returns_none() {
let result = detect_tool_version("__nonexistent_binary_oxo_call_test__");
assert!(result.is_none(), "nonexistent tool should return None");
}
#[test]
fn test_detect_tool_version_echo_command() {
let _result = detect_tool_version("echo");
}
#[test]
fn test_make_spinner_creates_without_panic() {
let pb = make_spinner("Test message");
pb.finish_and_clear();
}
#[test]
fn test_make_spinner_with_empty_message() {
let pb = make_spinner("");
pb.finish_and_clear();
}
#[test]
fn test_detect_output_files_bam_flag() {
let args: Vec<String> = vec!["--bam".to_string(), "output.bam".to_string()];
let files = detect_output_files(&args);
assert!(
files.contains(&"output.bam".to_string()),
"--bam flag should capture next arg"
);
}
#[test]
fn test_detect_output_files_short_b_flag() {
let args: Vec<String> = vec!["-b".to_string(), "output.bam".to_string()];
let files = detect_output_files(&args);
assert!(
files.contains(&"output.bam".to_string()),
"-b flag should capture next arg"
);
}
#[test]
fn test_detect_output_files_equals_form_bam() {
let args: Vec<String> = vec!["-b=output.bam".to_string()];
let files = detect_output_files(&args);
assert!(files.contains(&"output.bam".to_string()));
}
#[test]
fn test_detect_output_files_equals_form_empty_value_ignored() {
let args: Vec<String> = vec!["--output=".to_string()];
let files = detect_output_files(&args);
assert!(
!files.contains(&String::new()),
"empty value after = should not be collected"
);
}
#[test]
fn test_detect_output_files_positional_with_semicolon_excluded() {
let args: Vec<String> = vec!["input;rm -rf /".to_string()];
let files = detect_output_files(&args);
assert!(files.is_empty(), "args with ; should be excluded");
}
#[test]
fn test_detect_output_files_positional_with_pipe_excluded() {
let args: Vec<String> = vec!["input|cat".to_string()];
let files = detect_output_files(&args);
assert!(files.is_empty(), "args with | should be excluded");
}
#[test]
fn test_detect_output_files_positional_with_ampersand_excluded() {
let args: Vec<String> = vec!["input&output".to_string()];
let files = detect_output_files(&args);
assert!(files.is_empty(), "args with & should be excluded");
}
#[test]
fn test_detect_output_files_truncates_at_20() {
let mut args: Vec<String> = Vec::new();
for i in 0..25 {
args.push(format!("positional_{i}.bam"));
}
let files = detect_output_files(&args);
assert!(
files.len() <= 20,
"detect_output_files should cap at 20 entries"
);
}
#[test]
fn test_detect_output_files_no_dot_excluded() {
let args: Vec<String> = vec!["nodot".to_string(), "anotherword".to_string()];
let files = detect_output_files(&args);
assert!(
!files.contains(&"nodot".to_string()),
"arg without dot should not be collected"
);
}
#[test]
fn test_build_command_string_escapes_single_quotes_in_args() {
let args: Vec<String> = vec!["it's".to_string()];
let cmd = build_command_string("echo", &args);
assert!(
cmd.contains("'\\'"),
"single quote should be escaped as '\\'"
);
}
#[test]
fn test_is_companion_binary_bowtie2_build() {
assert!(is_companion_binary("bowtie2", "bowtie2-build"));
}
#[test]
fn test_is_companion_binary_hisat2_build() {
assert!(is_companion_binary("hisat2", "hisat2-build"));
}
#[test]
fn test_is_companion_binary_bismark_underscore_prefix() {
assert!(is_companion_binary("bismark", "bismark_genome_preparation"));
assert!(is_companion_binary(
"bismark",
"bismark_methylation_extractor"
));
}
#[test]
fn test_is_companion_binary_reverse_suffix() {
assert!(is_companion_binary("bismark", "deduplicate_bismark"));
}
#[test]
fn test_is_companion_binary_reverse_suffix_requires_prefix() {
assert!(!is_companion_binary("bismark", "_bismark"));
}
#[test]
fn test_is_companion_binary_flag_is_not_companion() {
assert!(!is_companion_binary("bowtie2", "-x"));
assert!(!is_companion_binary("bowtie2", "--no-unal"));
}
#[test]
fn test_is_companion_binary_filename_is_not_companion() {
assert!(!is_companion_binary("bowtie2", "bowtie2-input.fq"));
assert!(!is_companion_binary("samtools", "sorted.bam"));
}
#[test]
fn test_is_companion_binary_script_extension() {
assert!(is_companion_binary("manta", "configureManta.py"));
assert!(is_companion_binary("strelka2", "configureStrelka2.py"));
assert!(!is_companion_binary("homer", "annotatePeaks.pl"));
}
#[test]
fn test_is_companion_binary_script_prefix() {
assert!(!is_companion_binary("bbtools", "bbduk.sh"));
}
#[test]
fn test_is_companion_binary_no_prefix_match() {
assert!(!is_companion_binary("samtools", "sort"));
assert!(!is_companion_binary("samtools", "index"));
}
#[test]
fn test_effective_command_companion_redirects_tool() {
let args: Vec<String> = vec![
"bowtie2-build".to_string(),
"reference.fa".to_string(),
"ref_idx".to_string(),
];
let (eff_tool, eff_args) = effective_command("bowtie2", &args);
assert_eq!(eff_tool, "bowtie2-build");
assert_eq!(eff_args, &["reference.fa", "ref_idx"]);
}
#[test]
fn test_effective_command_normal_args_unchanged() {
let args: Vec<String> = vec![
"-x".to_string(),
"ref_idx".to_string(),
"-1".to_string(),
"R1.fq.gz".to_string(),
];
let (eff_tool, eff_args) = effective_command("bowtie2", &args);
assert_eq!(eff_tool, "bowtie2");
assert_eq!(eff_args, args.as_slice());
}
#[test]
fn test_effective_command_samtools_subcommand_unchanged() {
let args: Vec<String> = vec![
"sort".to_string(),
"-@".to_string(),
"4".to_string(),
"-o".to_string(),
"sorted.bam".to_string(),
];
let (eff_tool, eff_args) = effective_command("samtools", &args);
assert_eq!(eff_tool, "samtools");
assert_eq!(eff_args, args.as_slice());
}
#[test]
fn test_build_command_string_companion_binary() {
let args: Vec<String> = vec![
"bowtie2-build".to_string(),
"reference.fa".to_string(),
"ref_idx".to_string(),
];
let cmd = build_command_string("bowtie2", &args);
assert_eq!(cmd, "bowtie2-build reference.fa ref_idx");
assert!(
!cmd.starts_with("bowtie2 bowtie2-build"),
"must not double the tool name"
);
}
#[test]
fn test_effective_command_script_companion() {
let args: Vec<String> = vec![
"configureManta.py".to_string(),
"--bam".to_string(),
"input.bam".to_string(),
"--referenceFasta".to_string(),
"ref.fa".to_string(),
];
let (eff_tool, eff_args) = effective_command("manta", &args);
assert_eq!(eff_tool, "configureManta.py");
assert_eq!(
eff_args,
&["--bam", "input.bam", "--referenceFasta", "ref.fa"]
);
}
#[test]
fn test_effective_command_standalone_script() {
let args: Vec<String> = vec![
"bbduk.sh".to_string(),
"in=reads.fq".to_string(),
"out=clean.fq".to_string(),
"ref=adapters.fa".to_string(),
];
let (eff_tool, eff_args) = effective_command("bbtools", &args);
assert_eq!(eff_tool, "bbduk.sh");
assert_eq!(
eff_args,
&["in=reads.fq", "out=clean.fq", "ref=adapters.fa"]
);
}
#[test]
fn test_effective_command_rseqc_script() {
let args: Vec<String> = vec![
"infer_experiment.py".to_string(),
"-i".to_string(),
"aligned.bam".to_string(),
"-r".to_string(),
"ref.bed".to_string(),
];
let (eff_tool, eff_args) = effective_command("rseqc", &args);
assert_eq!(eff_tool, "infer_experiment.py");
assert_eq!(eff_args, &["-i", "aligned.bam", "-r", "ref.bed"]);
}
#[test]
fn test_is_script_executable() {
assert!(is_script_executable("bbduk.sh"));
assert!(is_script_executable("infer_experiment.py"));
assert!(is_script_executable("annotatePeaks.pl"));
assert!(is_script_executable("draw_fusions.R"));
assert!(is_script_executable("configureStrelkaGermlineWorkflow.py"));
assert!(!is_script_executable("reads.fastq.gz")); assert!(!is_script_executable("-i")); assert!(!is_script_executable("/usr/bin/script.py")); assert!(!is_script_executable("sort")); assert!(!is_script_executable("input.bam")); }
#[test]
fn test_effective_command_data_file_not_script() {
let args: Vec<String> = vec!["input.bam".to_string(), "-o".to_string()];
let (eff_tool, _) = effective_command("samtools", &args);
assert_eq!(eff_tool, "samtools");
}
}