use crate::state::SessionState; use serde::Serialize; use std::path::PathBuf; use std::process::Command; /// Helper to get the root path (cloned) without joining fn get_project_root(state: &SessionState) -> Result { state.get_project_root() } #[derive(Serialize, Debug, poem_openapi::Object)] pub struct CommandOutput { pub stdout: String, pub stderr: String, pub exit_code: i32, } /// Execute shell command logic (pure function for testing) async fn exec_shell_impl( command: String, args: Vec, root: PathBuf, ) -> Result { // Security Allowlist let allowed_commands = [ "git", "cargo", "npm", "yarn", "pnpm", "node", "bun", "ls", "find", "grep", "mkdir", "rm", "mv", "cp", "touch", "rustc", "rustfmt", ]; if !allowed_commands.contains(&command.as_str()) { return Err(format!("Command '{}' is not in the allowlist.", command)); } let output = tokio::task::spawn_blocking(move || { Command::new(&command) .args(&args) .current_dir(root) .output() }) .await .map_err(|e| format!("Task join error: {}", e))? .map_err(|e| format!("Failed to execute command: {}", e))?; Ok(CommandOutput { stdout: String::from_utf8_lossy(&output.stdout).to_string(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), exit_code: output.status.code().unwrap_or(-1), }) } pub async fn exec_shell( command: String, args: Vec, state: &SessionState, ) -> Result { let root = get_project_root(state)?; exec_shell_impl(command, args, root).await }