use crate::path_utils::expand_tilde_path; use crate::{DesktopRuntime, SettingsStore}; use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet, VecDeque}, path::{Path, PathBuf}, process::Command, sync::OnceLock, time::UNIX_EPOCH, }; use tokio::fs; const DEFAULT_FILE_SEARCH_LIMIT: usize = 60; const MAX_FILE_SEARCH_LIMIT: usize = 400; const FILE_SEARCH_MAX_CONCURRENCY: usize = 5; const FILE_SEARCH_EXCLUDED_DIRS: &[&str] = &[ "node_modules", ".git", "dist", "build", ".next", ".turbo", ".cache", "coverage", "tmp", "logs", ]; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct FileListEntry { name: String, path: String, is_directory: bool, is_file: bool, is_symbolic_link: bool, size: Option, modified_time: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct DirectoryListResult { directory: String, path: String, entries: Vec, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateDirectoryResponse { success: bool, path: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct FileSearchHit { name: String, path: String, relative_path: String, extension: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SearchFilesResponse { root: String, count: usize, files: Vec, } #[derive(Debug)] enum FsCommandError { NotFound, AccessDenied, NotDirectory, OutsideWorkspace, Other(String), } impl FsCommandError { fn to_list_message(&self) -> String { match self { FsCommandError::NotFound => "Directory not found".to_string(), FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => { "Access to directory denied".to_string() } FsCommandError::NotDirectory => "Specified path is not a directory".to_string(), FsCommandError::Other(message) => { let _ = message; "Failed to list directory".to_string() } } } fn to_search_message(&self) -> String { match self { FsCommandError::NotFound => "Directory not found".to_string(), FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => { "Access to directory denied".to_string() } FsCommandError::NotDirectory => "Specified path is not a directory".to_string(), FsCommandError::Other(message) => { let _ = message; "Failed to search files".to_string() } } } fn to_create_message(&self) -> String { match self { FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => { "Access to directory denied".to_string() } FsCommandError::NotDirectory => "Parent path must be a directory".to_string(), FsCommandError::Other(message) => { let _ = message; "Failed to create directory".to_string() } FsCommandError::NotFound => "Parent directory not found".to_string(), } } } impl From for FsCommandError { fn from(error: std::io::Error) -> Self { match error.kind() { std::io::ErrorKind::NotFound => FsCommandError::NotFound, std::io::ErrorKind::PermissionDenied => FsCommandError::AccessDenied, _ => FsCommandError::Other(error.to_string()), } } } #[tauri::command] pub async fn list_directory( path: Option, respect_gitignore: Option, state: tauri::State<'_, DesktopRuntime>, ) -> Result { let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; let resolved_path = resolve_sandboxed_path(path, &workspace_roots, default_root.as_ref()) .await .map_err(|err| err.to_list_message())?; let metadata = fs::metadata(&resolved_path) .await .map_err(|err| FsCommandError::from(err).to_list_message())?; if !metadata.is_dir() { return Err(FsCommandError::NotDirectory.to_list_message()); } // Re-check boundary after canonicalization to guard against traversal if !workspace_roots.is_empty() && !workspace_roots .iter() .any(|root| resolved_path.starts_with(root)) { return Err(FsCommandError::OutsideWorkspace.to_list_message()); } let mut entries = Vec::new(); let mut dir_entries = fs::read_dir(&resolved_path) .await .map_err(|err| FsCommandError::from(err).to_list_message())?; // Collect all entry names first for gitignore check let mut all_entries: Vec<(tokio::fs::DirEntry, String)> = Vec::new(); while let Some(entry) = dir_entries .next_entry() .await .map_err(|err| FsCommandError::from(err).to_list_message())? { let name = entry.file_name().to_string_lossy().to_string(); all_entries.push((entry, name)); } // Get gitignored paths if requested let ignored_names: HashSet = if respect_gitignore.unwrap_or(false) { let names: Vec = all_entries.iter().map(|(_, name)| name.clone()).collect(); if names.is_empty() { HashSet::new() } else { let cwd = resolved_path.clone(); tokio::task::spawn_blocking(move || { let output = Command::new("git") .arg("check-ignore") .arg("--") .args(&names) .current_dir(&cwd) .output(); match output { Ok(out) => { String::from_utf8_lossy(&out.stdout) .lines() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() } Err(_) => HashSet::new(), } }) .await .unwrap_or_default() } } else { HashSet::new() }; for (entry, name) in all_entries { // Skip gitignored entries if !ignored_names.is_empty() && ignored_names.contains(&name) { continue; } let file_type = entry .file_type() .await .map_err(|err| FsCommandError::from(err).to_list_message())?; let entry_path = entry.path(); let mut is_directory = file_type.is_dir(); let is_symlink = file_type.is_symlink(); if !is_directory && is_symlink { if let Ok(link_meta) = fs::metadata(&entry_path).await { is_directory = link_meta.is_dir(); } } let metadata = fs::metadata(&entry_path).await.ok(); let size = metadata .as_ref() .filter(|meta| meta.is_file()) .map(|meta| meta.len()); let modified_time = metadata .and_then(|meta| meta.modified().ok()) .and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_millis() as i64); entries.push(FileListEntry { name, path: normalize_path(&entry_path), is_directory, is_file: file_type.is_file(), is_symbolic_link: is_symlink, size, modified_time, }); } Ok(DirectoryListResult { directory: normalize_path(&resolved_path), path: normalize_path(&resolved_path), entries, }) } struct ScoredFileHit { hit: FileSearchHit, score: i32, } #[tauri::command] pub async fn search_files( directory: Option, query: Option, max_results: Option, state: tauri::State<'_, DesktopRuntime>, ) -> Result { let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; let resolved_root = resolve_sandboxed_path(directory, &workspace_roots, default_root.as_ref()) .await .map_err(|err| err.to_search_message())?; let limit = clamp_search_limit(max_results); let normalized_query = query.unwrap_or_default().trim().to_lowercase(); let match_all = normalized_query.is_empty(); // Collect more candidates for fuzzy matching, then sort and trim let collect_limit = if match_all { limit } else { (limit * 3).max(200) }; let mut candidates: Vec = Vec::new(); let mut queue = VecDeque::new(); let mut visited = HashSet::new(); queue.push_back(resolved_root.clone()); visited.insert(resolved_root.clone()); while !queue.is_empty() && candidates.len() < collect_limit { for _ in 0..FILE_SEARCH_MAX_CONCURRENCY { let Some(dir) = queue.pop_front() else { break; }; let mut entries = match fs::read_dir(&dir).await { Ok(entries) => entries, Err(_) => continue, }; while let Ok(Some(entry)) = entries.next_entry().await { let Ok(file_type) = entry.file_type().await else { continue; }; let name = entry.file_name(); let name_str = name.to_string_lossy(); if name_str.is_empty() || name_str.starts_with('.') { continue; } let entry_path = entry.path(); if file_type.is_dir() { if should_skip_directory(&name_str) { continue; } if visited.insert(entry_path.clone()) && candidates.len() < collect_limit { queue.push_back(entry_path); } continue; } if !file_type.is_file() { continue; } let relative_path = relative_path(&resolved_root, &entry_path); let extension = entry_path .extension() .and_then(|ext| ext.to_str()) .map(|ext| ext.to_lowercase()); let hit = FileSearchHit { name: name_str.to_string(), path: normalize_path(&entry_path), relative_path: relative_path.replace('\\', "/"), extension, }; if match_all { candidates.push(ScoredFileHit { hit, score: 0 }); } else { // Try fuzzy match against relative path (includes filename) if let Some(score) = fuzzy_match_score(&normalized_query, &relative_path) { candidates.push(ScoredFileHit { hit, score }); } } if candidates.len() >= collect_limit { break; } } if candidates.len() >= collect_limit { break; } } } // Sort by score descending, then by path length, then alphabetically if !match_all { candidates.sort_by(|a, b| match b.score.cmp(&a.score) { std::cmp::Ordering::Equal => { match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) { std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path), other => other, } } other => other, }); } let files: Vec = candidates .into_iter() .take(limit) .map(|scored| scored.hit) .collect(); Ok(SearchFilesResponse { root: normalize_path(&resolved_root), count: files.len(), files, }) } #[tauri::command] pub async fn create_directory( path: String, state: tauri::State<'_, DesktopRuntime>, ) -> Result { let trimmed = path.trim(); if trimmed.is_empty() { return Err("Path is required".to_string()); } let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref()) .await .map_err(|err| err.to_create_message())?; fs::create_dir_all(&resolved_path) .await .map_err(|err| FsCommandError::from(err).to_create_message())?; Ok(CreateDirectoryResponse { success: true, path: normalize_path(&resolved_path), }) } async fn resolve_sandboxed_path( path: Option, workspace_roots: &[PathBuf], default_root: Option<&PathBuf>, ) -> Result { let candidate_input = path .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()); let fallback_root = default_root .or_else(|| workspace_roots.first()) .cloned() .unwrap_or_else(default_home_directory); let candidate_path = match candidate_input { Some(value) => expand_tilde_path(value), None => fallback_root.clone(), }; let resolved = if candidate_path.is_absolute() { candidate_path } else { fallback_root.join(candidate_path) }; let canonicalized = fs::canonicalize(&resolved) .await .map_err(FsCommandError::from)?; if !workspace_roots.is_empty() && !workspace_roots .iter() .any(|root| canonicalized.starts_with(root)) { return Err(FsCommandError::OutsideWorkspace); } Ok(canonicalized) } async fn resolve_creatable_path( path: &str, workspace_roots: &[PathBuf], default_root: Option<&PathBuf>, ) -> Result { let candidate = expand_tilde_path(path); if candidate.as_os_str().is_empty() { return Err(FsCommandError::Other("Path is required".to_string())); } let fallback_root = default_root .or_else(|| workspace_roots.first()) .cloned() .unwrap_or_else(default_home_directory); let absolute = if candidate.is_absolute() { candidate } else { fallback_root.join(candidate) }; let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?; let canonical_parent = fs::canonicalize(parent) .await .map_err(FsCommandError::from)?; if !workspace_roots.is_empty() && !workspace_roots .iter() .any(|root| canonical_parent.starts_with(root)) { return Err(FsCommandError::OutsideWorkspace); } Ok(absolute) } async fn resolve_workspace_roots(settings: &SettingsStore) -> (Vec, Option) { let mut roots: Vec = Vec::new(); let mut default_root: Option = None; let settings_value = settings.load().await.ok(); if let Some(value) = settings_value.as_ref() { if let Some(active_id) = value.get("activeProjectId").and_then(|v| v.as_str()) { if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) { if let Some(active_path) = projects.iter().find_map(|entry| { let id = entry.get("id").and_then(|v| v.as_str())?; if id != active_id { return None; } entry.get("path").and_then(|v| v.as_str()) }) { if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(active_path)).await { default_root = Some(canonicalized.clone()); roots.push(canonicalized); } } } } if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) { for entry in projects { if let Some(path) = entry.get("path").and_then(|v| v.as_str()) { if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(path)).await { roots.push(canonicalized); } } } } if let Some(last_dir) = value.get("lastDirectory").and_then(|v| v.as_str()) { if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(last_dir)).await { if default_root.is_none() { default_root = Some(canonicalized.clone()); } roots.push(canonicalized); } } } if default_root.is_none() { if let Ok(Some(last_dir)) = settings.last_directory().await { if let Ok(canonicalized) = fs::canonicalize(last_dir).await { default_root = Some(canonicalized); } } } let mut deduped: Vec = Vec::new(); for root in roots { if !deduped.iter().any(|existing| existing == &root) { deduped.push(root); } } (deduped, default_root) } fn default_home_directory() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) } fn clamp_search_limit(value: Option) -> usize { let limit = value.unwrap_or(DEFAULT_FILE_SEARCH_LIMIT); limit.clamp(1, MAX_FILE_SEARCH_LIMIT) } fn should_skip_directory(name: &str) -> bool { if name.starts_with('.') { return true; } FILE_SEARCH_EXCLUDED_DIRS .iter() .any(|dir| dir.eq_ignore_ascii_case(name)) } /// Fuzzy match scoring function. /// Returns Some(score) if the query fuzzy-matches the candidate, None otherwise. /// Higher scores indicate better matches. fn fuzzy_match_score(query: &str, candidate: &str) -> Option { if query.is_empty() { return Some(0); } let q: Vec = query.to_lowercase().chars().collect(); let c: Vec = candidate.to_lowercase().chars().collect(); let c_str = candidate.to_lowercase(); // Fast path: exact substring match gets high score if c_str.contains(query) { if let Some(idx) = c_str.find(query) { let mut bonus: i32 = 0; if idx == 0 { bonus = 20; } else if let Some(prev) = c.get(idx.saturating_sub(1)) { if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' { bonus = 15; } } return Some(100 + bonus - (idx.min(20) as i32) - (c.len() as i32 / 5)); } } // Fuzzy match: all query chars must appear in order let mut score: i32 = 0; let mut last_index: i32 = -1; let mut consecutive: i32 = 0; for ch in &q { if *ch == ' ' { continue; } let search_start = if last_index < 0 { 0 } else { (last_index + 1) as usize }; let idx = c[search_start..].iter().position(|&c_char| c_char == *ch); match idx { None => return None, // No match Some(relative_idx) => { let idx = search_start + relative_idx; let gap = idx as i32 - last_index - 1; if gap == 0 { consecutive += 1; } else { consecutive = 0; } score += 10; score += (18 - idx as i32).max(0); // Prefer matches near start score -= gap.min(10); // Penalize gaps // Bonus for word boundary matches if idx == 0 { score += 12; } else if let Some(prev) = c.get(idx - 1) { if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' { score += 10; } } score += if consecutive > 0 { 12 } else { 0 }; // Bonus for consecutive matches last_index = idx as i32; } } } // Prefer shorter paths score += (24 - c.len() as i32 / 3).max(0); Some(score) } fn normalize_path(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } fn relative_path(root: &Path, target: &Path) -> String { target .strip_prefix(root) .map(|relative| normalize_path(relative)) .unwrap_or_else(|_| normalize_path(target)) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ReadFileResponse { content: String, path: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ReadFileBinaryResponse { data_url: String, path: String, } fn get_image_mime_type(file_path: &str) -> &'static str { let lower = file_path.to_lowercase(); if lower.ends_with(".png") { return "image/png"; } if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { return "image/jpeg"; } if lower.ends_with(".gif") { return "image/gif"; } if lower.ends_with(".svg") { return "image/svg+xml"; } if lower.ends_with(".webp") { return "image/webp"; } if lower.ends_with(".ico") { return "image/x-icon"; } if lower.ends_with(".bmp") { return "image/bmp"; } if lower.ends_with(".avif") { return "image/avif"; } "application/octet-stream" } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct WriteFileResponse { success: bool, path: String, } #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CommandResult { command: String, success: bool, exit_code: Option, stdout: Option, stderr: Option, error: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ExecCommandsResponse { success: bool, results: Vec, } #[tauri::command] pub async fn read_file( path: String, state: tauri::State<'_, DesktopRuntime>, ) -> Result { let trimmed = path.trim(); if trimmed.is_empty() { return Err("Path is required".to_string()); } let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref()) .await .map_err(|_| "File not found or access denied".to_string())?; let metadata = fs::metadata(&resolved_path) .await .map_err(|_| "File not found".to_string())?; if !metadata.is_file() { return Err("Specified path is not a file".to_string()); } let content = fs::read_to_string(&resolved_path) .await .map_err(|err| format!("Failed to read file: {}", err))?; Ok(ReadFileResponse { content, path: normalize_path(&resolved_path), }) } #[tauri::command] pub async fn read_file_binary( path: String, state: tauri::State<'_, DesktopRuntime>, ) -> Result { use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; const MAX_BYTES: u64 = 10 * 1024 * 1024; let trimmed = path.trim(); if trimmed.is_empty() { return Err("Path is required".to_string()); } let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref()) .await .map_err(|_| "File not found or access denied".to_string())?; let metadata = fs::metadata(&resolved_path) .await .map_err(|_| "File not found".to_string())?; if !metadata.is_file() { return Err("Specified path is not a file".to_string()); } if metadata.len() > MAX_BYTES { return Err("File too large".to_string()); } let bytes = fs::read(&resolved_path) .await .map_err(|err| format!("Failed to read file: {}", err))?; let mime_type = get_image_mime_type(trimmed); let data_url = format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)); Ok(ReadFileBinaryResponse { data_url, path: normalize_path(&resolved_path), }) } #[tauri::command] pub async fn write_file( path: String, content: String, state: tauri::State<'_, DesktopRuntime>, ) -> Result { let trimmed = path.trim(); if trimmed.is_empty() { return Err("Path is required".to_string()); } let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref()) .await .map_err(|err| err.to_create_message())?; // Ensure parent directory exists if let Some(parent) = resolved_path.parent() { fs::create_dir_all(parent) .await .map_err(|err| format!("Failed to create parent directory: {}", err))?; } fs::write(&resolved_path, content) .await .map_err(|err| format!("Failed to write file: {}", err))?; Ok(WriteFileResponse { success: true, path: normalize_path(&resolved_path), }) } static CACHED_LOGIN_SHELL_PATH: OnceLock> = OnceLock::new(); #[cfg(target_os = "macos")] fn get_user_shell() -> Option { let username = dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?; let output = Command::new("dscl") .args([".", "-read", &format!("/Users/{}", username), "UserShell"]) .output() .ok()?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.split(':').nth(1).map(|s| s.trim().to_string()) } else { None } } #[cfg(all(unix, not(target_os = "macos")))] fn get_user_shell() -> Option { std::env::var("SHELL").ok() } #[cfg(not(unix))] fn get_user_shell() -> Option { None } fn build_shell_path_command(shell: &str) -> Vec { let shell_name = std::path::Path::new(shell) .file_name() .and_then(|s| s.to_str()) .unwrap_or("sh"); match shell_name { "nu" | "nushell" => vec![ "-l".to_string(), "-i".to_string(), "-c".to_string(), "echo $\"__PATH__=($env.PATH | str join (char esep))\"".to_string(), ], "bash" => vec![ "-lic".to_string(), "source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"".to_string(), ], "fish" => vec![ "-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string(), ], _ => vec![ "-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string(), ], } } fn detect_login_shell_path() -> Option { #[cfg(not(unix))] { None } #[cfg(unix)] { let shell = get_user_shell().unwrap_or_else(|| "/bin/zsh".into()); let args = build_shell_path_command(&shell); let output = match Command::new(&shell).args(&args).output() { Ok(o) => o, Err(_) => return None, }; if !output.status.success() { return None; } let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { if let Some(path) = line.strip_prefix("__PATH__=") { if !path.is_empty() { return Some(path.to_string()); } } } None } } fn get_cached_login_shell_path() -> Option<&'static String> { CACHED_LOGIN_SHELL_PATH .get_or_init(detect_login_shell_path) .as_ref() } fn merge_paths(login_path: &str, current: &str) -> String { let mut segments = Vec::new(); let mut seen = HashSet::new(); for part in login_path.split(':').chain(current.split(':')) { if !part.is_empty() && !seen.contains(part) { seen.insert(part.to_string()); segments.push(part); } } segments.join(":") } fn build_augmented_env() -> HashMap { let mut env: HashMap = std::env::vars().collect(); if let Some(login_path) = get_cached_login_shell_path() { let current = env.get("PATH").cloned().unwrap_or_default(); env.insert("PATH".to_string(), merge_paths(login_path, ¤t)); } env } #[tauri::command] pub async fn exec_commands( commands: Vec, cwd: String, state: tauri::State<'_, DesktopRuntime>, ) -> Result { if commands.is_empty() { return Err("Commands array is required".to_string()); } let cwd_trimmed = cwd.trim(); if cwd_trimmed.is_empty() { return Err("Working directory (cwd) is required".to_string()); } let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; let resolved_cwd = resolve_sandboxed_path(Some(cwd_trimmed.to_string()), &workspace_roots, default_root.as_ref()) .await .map_err(|_| "Working directory not found or access denied".to_string())?; let metadata = fs::metadata(&resolved_cwd) .await .map_err(|_| "Working directory not found".to_string())?; if !metadata.is_dir() { return Err("Specified cwd is not a directory".to_string()); } let shell = std::env::var("SHELL").unwrap_or_else(|_| { if cfg!(windows) { "cmd.exe".to_string() } else { "/bin/sh".to_string() } }); let shell_flag = if cfg!(windows) { "/c" } else { "-c" }; let augmented_env = build_augmented_env(); let mut results = Vec::new(); for cmd in commands { let cmd_trimmed = cmd.trim(); if cmd_trimmed.is_empty() { results.push(CommandResult { command: cmd.clone(), success: false, exit_code: None, stdout: None, stderr: None, error: Some("Invalid command".to_string()), }); continue; } let cwd_clone = resolved_cwd.clone(); let shell_clone = shell.clone(); let cmd_clone = cmd_trimmed.to_string(); let env_clone = augmented_env.clone(); // Run command synchronously in blocking task let result = tokio::task::spawn_blocking(move || { match Command::new(&shell_clone) .arg(shell_flag) .arg(&cmd_clone) .current_dir(&cwd_clone) .envs(&env_clone) .output() { Ok(output) => CommandResult { command: cmd_clone, success: output.status.success(), exit_code: output.status.code(), stdout: Some(String::from_utf8_lossy(&output.stdout).trim().to_string()), stderr: Some(String::from_utf8_lossy(&output.stderr).trim().to_string()), error: None, }, Err(err) => CommandResult { command: cmd_clone, success: false, exit_code: None, stdout: None, stderr: None, error: Some(err.to_string()), }, } }) .await .unwrap_or_else(|err| CommandResult { command: cmd.clone(), success: false, exit_code: None, stdout: None, stderr: None, error: Some(format!("Task failed: {}", err)), }); results.push(result); } let all_succeeded = results.iter().all(|r| r.success); Ok(ExecCommandsResponse { success: all_succeeded, results, }) }