diff --git a/src/config/mod.rs b/src/config/mod.rs index a3f4c11..d849326 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -3,11 +3,26 @@ pub mod os; pub mod types; pub mod validate; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::error::{Error, Result}; use types::AppConfig; +/// Return the directory that relative paths inside a config file should be +/// resolved against. For local configs this is the config file's parent +/// directory (after canonicalizing); for URLs and unresolvable paths we fall +/// back to `fallback`. +pub fn resolve_config_dir(path_or_url: &str, fallback: &Path) -> PathBuf { + if is_url(path_or_url) { + return fallback.to_path_buf(); + } + Path::new(path_or_url) + .canonicalize() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| fallback.to_path_buf()) +} + /// Load a config from a local path or URL, auto-detecting format from extension. /// If the path has no extension, tries `.yaml`, `.yml`, then `.json`. /// diff --git a/src/config/types.rs b/src/config/types.rs index 176edfa..18222dd 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -150,30 +150,60 @@ impl<'de> Deserialize<'de> for CommandEntry { impl std::fmt::Display for CommandEntry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - CommandEntry::Copy(args) => write!(f, "copy: {} -> {}", args.src, args.target), - CommandEntry::Symlink(args) => write!(f, "symlink: {} -> {}", args.src, args.target), - CommandEntry::Clone(args) => write!(f, "clone: {} -> {}", args.url, args.target), - CommandEntry::Run(args) => { - let cmds = args.all_command_strings(); - if cmds.is_empty() { - write!(f, "run: (no commands)") - } else if cmds.len() == 1 { - write!(f, "run: {}", cmds[0]) - } else { - write!(f, "run: {} commands", cmds.len()) - } - } - CommandEntry::MachineSetup(args) => { - write!(f, "machine_setup: {}", args.config)?; - if let Some(task) = &args.task { - write!(f, " (task: {task})")?; - } - Ok(()) - } + CommandEntry::Copy(args) => args.fmt(f), + CommandEntry::Symlink(args) => args.fmt(f), + CommandEntry::Clone(args) => args.fmt(f), + CommandEntry::Run(args) => args.fmt(f), + CommandEntry::MachineSetup(args) => args.fmt(f), + } + } +} + +impl std::fmt::Display for CopyArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let prefix = if self.sudo { "copy (sudo)" } else { "copy" }; + write!(f, "{prefix}: {} -> {}", self.src, self.target) + } +} + +impl std::fmt::Display for SymlinkArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let prefix = if self.sudo { + "symlink (sudo)" + } else { + "symlink" + }; + write!(f, "{prefix}: {} -> {}", self.src, self.target) + } +} + +impl std::fmt::Display for CloneArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "clone: {} -> {}", self.url, self.target) + } +} + +impl std::fmt::Display for RunArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut iter = self.all_command_strings(); + match (iter.next(), iter.next()) { + (None, _) => write!(f, "run: (no commands)"), + (Some(c), None) => write!(f, "run: {c}"), + (Some(_), Some(_)) => write!(f, "run: {} commands", 2 + iter.count()), } } } +impl std::fmt::Display for MachineSetupArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "machine_setup: {}", self.config)?; + if let Some(task) = &self.task { + write!(f, " (task: {task})")?; + } + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CopyArgs { pub src: String, @@ -230,14 +260,17 @@ pub struct RunArgs { } impl RunArgs { - /// Get all command strings regardless of mode (for display purposes). - pub fn all_command_strings(&self) -> Vec<&str> { - let mut cmds = Vec::new(); - cmds.extend(self.commands.as_slice().iter().map(|s| s.as_str())); - cmds.extend(self.install.as_slice().iter().map(|s| s.as_str())); - cmds.extend(self.update.as_slice().iter().map(|s| s.as_str())); - cmds.extend(self.uninstall.as_slice().iter().map(|s| s.as_str())); - cmds + /// Iterate all command strings regardless of mode (for display purposes). + /// Returns an iterator so callers that only need count/first/is_empty + /// don't force an intermediate Vec allocation. + pub fn all_command_strings(&self) -> impl Iterator { + self.commands + .as_slice() + .iter() + .chain(self.install.as_slice().iter()) + .chain(self.update.as_slice().iter()) + .chain(self.uninstall.as_slice().iter()) + .map(|s| s.as_str()) } /// Get commands for a specific mode. @@ -310,15 +343,16 @@ impl<'de> Deserialize<'de> for StringOrVec { impl AppConfig { /// Check if any commands in the selected tasks require sudo. pub fn requires_sudo(&self, task_names: &[String]) -> bool { + let selected: std::collections::HashSet<&str> = + task_names.iter().map(String::as_str).collect(); self.tasks .iter() - .filter(|(name, _)| task_names.iter().any(|t| t == *name)) + .filter(|(name, _)| selected.contains(name.as_str())) .any(|(_, task)| { task.commands.iter().any(|cmd| match cmd { - CommandEntry::Run(args) => args - .all_command_strings() - .iter() - .any(|s| s.contains("sudo")), + CommandEntry::Run(args) => { + args.all_command_strings().any(|s| s.contains("sudo")) + } CommandEntry::Copy(args) => args.sudo, CommandEntry::Symlink(args) => args.sudo, _ => false, diff --git a/src/config/validate.rs b/src/config/validate.rs index 6324de6..167cd31 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -146,7 +146,7 @@ pub fn validate_config(config: &AppConfig, config_dir: &Path) -> Vec { - if args.all_command_strings().is_empty() { + if args.all_command_strings().next().is_none() { issues.push(ValidationIssue { task_name: name.clone(), message: format!("Run command has no commands defined: {cmd}"), diff --git a/src/engine/commands/clone.rs b/src/engine/commands/clone.rs index bc42ec6..f15aeeb 100644 --- a/src/engine/commands/clone.rs +++ b/src/engine/commands/clone.rs @@ -1,11 +1,11 @@ use async_trait::async_trait; -use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use crate::config::types::CloneArgs; use crate::engine::context::CommandContext; use crate::error::{Error, Result}; use crate::utils::path::expand_path; +use crate::utils::process; use super::CommandExecutor; @@ -75,7 +75,7 @@ impl CommandExecutor for CloneCommand { } fn description(&self) -> String { - format!("clone: {} -> {}", self.args.url, self.args.target) + self.args.to_string() } } @@ -93,44 +93,15 @@ async fn run_git_command( cmd.current_dir(dir); } - let mut child = cmd + let child = cmd .spawn() .map_err(|e| Error::GitFailed(format!("Failed to spawn git: {e}")))?; - let stdout_handle = child.stdout.take().map(|stdout| { - let ctx_clone = ctx.clone(); - tokio::spawn(async move { - let reader = BufReader::new(stdout); - let mut lines = reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - ctx_clone.log(line); - } - }) - }); - - let stderr_handle = child.stderr.take().map(|stderr| { - let ctx_clone = ctx.clone(); - tokio::spawn(async move { - let reader = BufReader::new(stderr); - let mut lines = reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - ctx_clone.log(line); - } - }) - }); - - let status = child - .wait() + // git emits progress on stderr — don't tag those lines as errors. + let status = process::stream_and_wait(child, ctx, process::StderrLabel::Plain) .await .map_err(|e| Error::GitFailed(format!("Failed to wait for git: {e}")))?; - if let Some(h) = stdout_handle { - let _ = h.await; - } - if let Some(h) = stderr_handle { - let _ = h.await; - } - if !status.success() { return Err(Error::GitFailed(format!( "git {} exited with code {}", diff --git a/src/engine/commands/copy.rs b/src/engine/commands/copy.rs index 17d6ef7..1178487 100644 --- a/src/engine/commands/copy.rs +++ b/src/engine/commands/copy.rs @@ -1,11 +1,10 @@ use async_trait::async_trait; use std::path::Path; -use walkdir::WalkDir; use crate::config::types::CopyArgs; use crate::engine::context::CommandContext; use crate::error::{Error, Result}; -use crate::utils::path::{expand_path, should_ignore}; +use crate::utils::path::{expand_path, walk_relative}; use crate::utils::sudo; use super::CommandExecutor; @@ -74,31 +73,20 @@ impl CommandExecutor for CopyCommand { remove_file(&dest, use_sudo)?; } } else { - for entry in WalkDir::new(&src).into_iter().filter_map(|e| e.ok()) { - if entry.file_type().is_file() { - let relative = entry.path().strip_prefix(&src).unwrap(); - if should_ignore(relative, &self.args.ignore) { - continue; - } - let dest = target.join(relative); - if dest.exists() { - ctx.log(format!("Removing: {}", dest.display())); - remove_file(&dest, use_sudo)?; - } + walk_relative(&src, &target, &self.args.ignore, |entry, dest| { + if entry.file_type().is_file() && dest.exists() { + ctx.log(format!("Removing: {}", dest.display())); + remove_file(dest, use_sudo)?; } - } + Ok(()) + })?; } Ok(()) } fn description(&self) -> String { - let prefix = if self.args.sudo { - "copy (sudo)" - } else { - "copy" - }; - format!("{prefix}: {} -> {}", self.args.src, self.args.target) + self.args.to_string() } } @@ -156,20 +144,11 @@ fn copy_directory( use_sudo: bool, ctx: &CommandContext, ) -> Result<()> { - for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) { - let relative = entry.path().strip_prefix(src).unwrap(); - - if should_ignore(relative, ignore) { - continue; - } - - let dest = target.join(relative); - + walk_relative(src, target, ignore, |entry, dest| { if entry.file_type().is_dir() { - mkdir(&dest, use_sudo)?; + mkdir(dest, use_sudo) } else { - copy_file(entry.path(), &dest, use_sudo, ctx)?; + copy_file(entry.path(), dest, use_sudo, ctx) } - } - Ok(()) + }) } diff --git a/src/engine/commands/mod.rs b/src/engine/commands/mod.rs index b76330c..f9c95eb 100644 --- a/src/engine/commands/mod.rs +++ b/src/engine/commands/mod.rs @@ -22,13 +22,15 @@ pub trait CommandExecutor: Send + Sync { fn description(&self) -> String; } -/// Create a command executor from a config entry. -pub fn create_executor(entry: &CommandEntry) -> Box { +/// Create a command executor from a config entry. Takes ownership so the +/// args struct moves directly into the executor without an intermediate +/// clone inside each match arm. +pub fn create_executor(entry: CommandEntry) -> Box { match entry { - CommandEntry::Copy(args) => Box::new(copy::CopyCommand::new(args.clone())), - CommandEntry::Symlink(args) => Box::new(symlink::SymlinkCommand::new(args.clone())), - CommandEntry::Clone(args) => Box::new(clone::CloneCommand::new(args.clone())), - CommandEntry::Run(args) => Box::new(run::RunCommand::new(args.clone())), - CommandEntry::MachineSetup(args) => Box::new(setup::SetupCommand::new(args.clone())), + CommandEntry::Copy(args) => Box::new(copy::CopyCommand::new(args)), + CommandEntry::Symlink(args) => Box::new(symlink::SymlinkCommand::new(args)), + CommandEntry::Clone(args) => Box::new(clone::CloneCommand::new(args)), + CommandEntry::Run(args) => Box::new(run::RunCommand::new(args)), + CommandEntry::MachineSetup(args) => Box::new(setup::SetupCommand::new(args)), } } diff --git a/src/engine/commands/run.rs b/src/engine/commands/run.rs index 01b0d68..5d6c034 100644 --- a/src/engine/commands/run.rs +++ b/src/engine/commands/run.rs @@ -1,11 +1,10 @@ use async_trait::async_trait; -use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use crate::config::types::RunArgs; use crate::engine::context::CommandContext; use crate::error::{Error, Result}; -use crate::utils::shell; +use crate::utils::{process, shell}; use super::CommandExecutor; @@ -34,14 +33,7 @@ impl CommandExecutor for RunCommand { } fn description(&self) -> String { - let cmds = self.args.all_command_strings(); - if cmds.is_empty() { - "run: (no commands)".to_string() - } else if cmds.len() == 1 { - format!("run: {}", cmds[0]) - } else { - format!("run: {} commands", cmds.len()) - } + self.args.to_string() } } @@ -59,104 +51,82 @@ async fn run_for_mode( let active_shell = args.shell.as_ref().unwrap_or(&ctx.default_shell); let script = shell::build_shell_command(commands, active_shell, &args.env)?; - // Write temp script - let script_path = shell::write_temp_script(&script, active_shell, &ctx.temp_dir)?; - ctx.log(format!( "Running {} command(s) with {}", commands.len(), active_shell )); - let result = execute_script(&script_path, active_shell, ctx).await; - - // Cleanup temp script - let _ = std::fs::remove_file(&script_path); - - result + match active_shell { + crate::config::types::Shell::Bash | crate::config::types::Shell::Zsh => { + // Pipe the in-memory script straight to the shell over stdin — + // no temp file round-trip needed. + execute_script_stdin(&script, active_shell, ctx).await + } + crate::config::types::Shell::PowerShell => { + // PowerShell needs -File for reliable execution on Windows, so + // we still materialize a script on disk for this path only. + let script_path = shell::write_temp_script(&script, active_shell, &ctx.temp_dir)?; + let result = execute_script_file(&script_path, active_shell, ctx).await; + let _ = std::fs::remove_file(&script_path); + result + } + } } -async fn execute_script( - script_path: &std::path::Path, +async fn execute_script_stdin( + script: &str, shell_type: &crate::config::types::Shell, ctx: &CommandContext, ) -> Result<()> { let shell_bin = shell::shell_binary(shell_type); let mut cmd = Command::new(shell_bin); + cmd.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); - let script_content = std::fs::read_to_string(script_path) - .map_err(|e| Error::ShellFailed(format!("Failed to read script: {e}")))?; + let mut child = cmd + .spawn() + .map_err(|e| Error::ShellFailed(format!("Failed to spawn {shell_bin}: {e}")))?; - match shell_type { - crate::config::types::Shell::Bash | crate::config::types::Shell::Zsh => { - // Pipe script via stdin to avoid path issues on Windows - // and newline-in-args issues with CreateProcess - cmd.stdin(std::process::Stdio::piped()); - } - crate::config::types::Shell::PowerShell => { - cmd.arg("-File").arg(script_path); - } + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + stdin + .write_all(script.as_bytes()) + .await + .map_err(|e| Error::ShellFailed(format!("Failed to write to stdin: {e}")))?; + // Drop stdin to signal EOF } + wait_with_output(child, ctx).await +} + +async fn execute_script_file( + script_path: &std::path::Path, + shell_type: &crate::config::types::Shell, + ctx: &CommandContext, +) -> Result<()> { + let shell_bin = shell::shell_binary(shell_type); + + let mut cmd = Command::new(shell_bin); + cmd.arg("-File").arg(script_path); + cmd.stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); - let mut child = cmd + let child = cmd .spawn() .map_err(|e| Error::ShellFailed(format!("Failed to spawn {shell_bin}: {e}")))?; - // Write script to stdin for bash/zsh - if matches!( - shell_type, - crate::config::types::Shell::Bash | crate::config::types::Shell::Zsh - ) { - if let Some(mut stdin) = child.stdin.take() { - use tokio::io::AsyncWriteExt; - stdin - .write_all(script_content.as_bytes()) - .await - .map_err(|e| Error::ShellFailed(format!("Failed to write to stdin: {e}")))?; - // Drop stdin to signal EOF - } - } + wait_with_output(child, ctx).await +} - // Stream stdout and stderr concurrently, then wait for process - let stdout_handle = child.stdout.take().map(|stdout| { - let ctx_clone = ctx.clone(); - tokio::spawn(async move { - let reader = BufReader::new(stdout); - let mut lines = reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - ctx_clone.log(line); - } - }) - }); - - let stderr_handle = child.stderr.take().map(|stderr| { - let ctx_clone = ctx.clone(); - tokio::spawn(async move { - let reader = BufReader::new(stderr); - let mut lines = reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - ctx_clone.log(format!("[stderr] {line}")); - } - }) - }); - - let status = child - .wait() +async fn wait_with_output(child: tokio::process::Child, ctx: &CommandContext) -> Result<()> { + let status = process::stream_and_wait(child, ctx, process::StderrLabel::Prefixed) .await .map_err(|e| Error::ShellFailed(format!("Failed to wait for shell: {e}")))?; - // Wait for output streams to finish flushing - if let Some(h) = stdout_handle { - let _ = h.await; - } - if let Some(h) = stderr_handle { - let _ = h.await; - } - if !status.success() { return Err(Error::ShellFailed(format!( "Shell exited with code {}", diff --git a/src/engine/commands/setup.rs b/src/engine/commands/setup.rs index f11a98c..9b7c2dc 100644 --- a/src/engine/commands/setup.rs +++ b/src/engine/commands/setup.rs @@ -32,39 +32,29 @@ impl CommandExecutor for SetupCommand { } fn description(&self) -> String { - let mut desc = format!("machine_setup: {}", self.args.config); - if let Some(task) = &self.args.task { - desc.push_str(&format!(" (task: {task})")); - } - desc + self.args.to_string() } } async fn run_sub_config(args: &MachineSetupArgs, ctx: &CommandContext) -> Result<()> { - let is_url = args.config.starts_with("http://") || args.config.starts_with("https://"); + let is_url = crate::config::is_url(&args.config); - let config_str = if is_url { - args.config.clone() + // For URLs we pass the string through to load_config; for local paths + // we resolve via expand_path against the parent's config_dir. + let config_str: std::borrow::Cow<'_, str> = if is_url { + std::borrow::Cow::Borrowed(&args.config) } else { - let config_path = expand_path(&args.config, Some(&ctx.config_dir)); - config_path.to_string_lossy().to_string() + let path = expand_path(&args.config, Some(&ctx.config_dir)); + std::borrow::Cow::Owned(path.to_string_lossy().into_owned()) }; ctx.log(format!("Loading sub-config: {config_str}")); let config = crate::config::load_config(&config_str)?; - // Resolve the sub-config's directory for its own relative paths - // URLs fall back to parent's config_dir - let sub_config_dir = if is_url { - ctx.config_dir.clone() - } else { - std::path::Path::new(&config_str) - .canonicalize() - .ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())) - .unwrap_or_else(|| ctx.config_dir.clone()) - }; + // Resolve the sub-config's directory for its own relative paths. URLs + // and unresolvable paths fall back to the parent's config_dir. + let sub_config_dir = crate::config::resolve_config_dir(&config_str, &ctx.config_dir); let runner = crate::engine::runner::TaskRunner::new(config, ctx.mode.clone(), ctx.event_tx.clone()) diff --git a/src/engine/commands/symlink.rs b/src/engine/commands/symlink.rs index a037e9a..04202a1 100644 --- a/src/engine/commands/symlink.rs +++ b/src/engine/commands/symlink.rs @@ -1,10 +1,9 @@ use async_trait::async_trait; -use walkdir::WalkDir; use crate::config::types::SymlinkArgs; use crate::engine::context::CommandContext; use crate::error::{Error, Result}; -use crate::utils::path::{expand_path, should_ignore}; +use crate::utils::path::{expand_path, walk_relative}; use crate::utils::sudo; use super::CommandExecutor; @@ -46,21 +45,13 @@ impl CommandExecutor for SymlinkCommand { create_symlink(&src, &dest, self.args.force, use_sudo, ctx)?; } else { mkdir(&target, use_sudo)?; - for entry in WalkDir::new(&src).into_iter().filter_map(|e| e.ok()) { - let relative = entry.path().strip_prefix(&src).unwrap(); - - if should_ignore(relative, &self.args.ignore) { - continue; - } - - let dest = target.join(relative); - + walk_relative(&src, &target, &self.args.ignore, |entry, dest| { if entry.file_type().is_dir() { - mkdir(&dest, use_sudo)?; + mkdir(dest, use_sudo) } else { - create_symlink(entry.path(), &dest, self.args.force, use_sudo, ctx)?; + create_symlink(entry.path(), dest, self.args.force, use_sudo, ctx) } - } + })?; } Ok(()) @@ -83,28 +74,19 @@ impl CommandExecutor for SymlinkCommand { }; remove_symlink(&dest, use_sudo, ctx)?; } else { - for entry in WalkDir::new(&src).into_iter().filter_map(|e| e.ok()) { + walk_relative(&src, &target, &self.args.ignore, |entry, dest| { if entry.file_type().is_file() { - let relative = entry.path().strip_prefix(&src).unwrap(); - if should_ignore(relative, &self.args.ignore) { - continue; - } - let dest = target.join(relative); - remove_symlink(&dest, use_sudo, ctx)?; + remove_symlink(dest, use_sudo, ctx)?; } - } + Ok(()) + })?; } Ok(()) } fn description(&self) -> String { - let prefix = if self.args.sudo { - "symlink (sudo)" - } else { - "symlink" - }; - format!("{prefix}: {} -> {}", self.args.src, self.args.target) + self.args.to_string() } } diff --git a/src/engine/runner.rs b/src/engine/runner.rs index 082c9e5..b30b4cd 100644 --- a/src/engine/runner.rs +++ b/src/engine/runner.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use tokio::sync::mpsc; @@ -61,8 +62,10 @@ impl TaskRunner { /// Run specific tasks by name. pub async fn run_tasks(&self, task_names: &[String], force: bool) -> Result<()> { - // Resolve dependency order via topological sort + // Resolve dependency order via topological sort. When no task has + // dependencies, this borrows `task_names` instead of cloning it. let ordered = self.topological_sort(task_names)?; + let ordered: &[String] = &ordered; let temp_dir = expand_path(&self.config.temp_dir, None); let mut history = History::load(&temp_dir).unwrap_or_default(); @@ -73,7 +76,7 @@ impl TaskRunner { if self.config.parallel { // Parallel execution with dependency layers - let layers = self.dependency_layers(&ordered); + let layers = self.dependency_layers(ordered); for layer in layers { let mut handles = Vec::new(); @@ -122,7 +125,7 @@ impl TaskRunner { } } else { // Sequential execution - for name in &ordered { + for name in ordered { let task_config = &self.config.tasks[name]; if let Some(reason) = self.should_skip(task_config, name, force, &history) { @@ -211,8 +214,8 @@ impl TaskRunner { /// Topological sort of tasks respecting depends_on. /// Returns tasks in dependency order. Includes transitive dependencies - /// of the requested tasks. - fn topological_sort(&self, requested: &[String]) -> Result> { + /// of the requested tasks. Borrows the input when no sorting is needed. + fn topological_sort<'a>(&self, requested: &'a [String]) -> Result> { let all_tasks = &self.config.tasks; // If no task has dependencies, preserve original order @@ -221,7 +224,7 @@ impl TaskRunner { .filter_map(|n| all_tasks.get(n)) .any(|t| !t.depends_on.is_empty()); if !has_deps { - return Ok(requested.to_vec()); + return Ok(Cow::Borrowed(requested)); } // Collect all needed tasks (requested + transitive deps) @@ -293,7 +296,7 @@ impl TaskRunner { return Err(Error::CyclicDependency(remaining.join(", "))); } - Ok(sorted) + Ok(Cow::Owned(sorted)) } /// Group tasks into dependency layers for parallel execution. @@ -406,7 +409,7 @@ async fn run_task( }); let executors: Vec> = - task.commands.iter().map(create_executor).collect(); + task.commands.iter().cloned().map(create_executor).collect(); if task.parallel { // Run commands in parallel diff --git a/src/main.rs b/src/main.rs index cf566ae..832bf45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,11 +31,8 @@ async fn main() -> anyhow::Result<()> { // Handle validate command if cli.command == Command::Validate { - let config_dir = std::path::Path::new(&cli.config) - .canonicalize() - .ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())) - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let cwd = std::env::current_dir().unwrap_or_default(); + let config_dir = config::resolve_config_dir(&cli.config, &cwd); let issues = config::validate::validate_config(&app_config, &config_dir); if issues.is_empty() { @@ -72,22 +69,13 @@ async fn main() -> anyhow::Result<()> { } // Resolve config directory for relative paths (URLs fall back to cwd) - let config_dir = std::path::Path::new(&cli.config) - .canonicalize() - .ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())) - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let cwd = std::env::current_dir().unwrap_or_default(); + let config_dir = config::resolve_config_dir(&cli.config, &cwd); // Create event channel and cancellation token let (event_tx, event_rx) = mpsc::unbounded_channel::(); let cancel = CancellationToken::new(); - // Set up runner - let runner = TaskRunner::new(app_config.clone(), cli.command.clone(), event_tx) - .with_config_dir(config_dir); - let force = cli.force; - let task_names_clone = task_names.clone(); - // Determine if we should use the TUI let use_tui = !cli.no_tui && std::io::stdout().is_terminal(); @@ -96,6 +84,12 @@ async fn main() -> anyhow::Result<()> { pre_authenticate_sudo(); } + // Set up runner (moves app_config) + let runner = + TaskRunner::new(app_config, cli.command.clone(), event_tx).with_config_dir(config_dir); + let force = cli.force; + let task_names_clone = task_names.clone(); + if use_tui { // Spawn engine in background let engine_cancel = cancel.clone(); @@ -218,15 +212,18 @@ fn pre_authenticate_sudo() { } fn select_tasks(config: &config::types::AppConfig) -> anyhow::Result> { - let task_names: Vec = config.tasks.keys().cloned().collect(); + let mut task_names: Vec = config.tasks.keys().cloned().collect(); let selections = dialoguer::MultiSelect::new() .with_prompt("Select tasks to run") .items(&task_names) .interact()?; + // `std::mem::take` transfers ownership of each selected name out of the + // vec without cloning. `selections` is sorted and unique, so no slot is + // taken twice. Ok(selections .into_iter() - .map(|i| task_names[i].clone()) + .map(|i| std::mem::take(&mut task_names[i])) .collect()) } diff --git a/src/tui/widgets/task_list.rs b/src/tui/widgets/task_list.rs index e6e8d53..801a469 100644 --- a/src/tui/widgets/task_list.rs +++ b/src/tui/widgets/task_list.rs @@ -18,14 +18,13 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { (area, None) }; - let filtered_set: std::collections::HashSet = - app.filtered_indices.iter().copied().collect(); - + // `filtered_indices` is already the exact set we want to render and is + // maintained in ascending order by `update_filter`, so we can iterate it + // directly without rebuilding a HashSet every frame. let items: Vec = app - .tasks + .filtered_indices .iter() - .enumerate() - .filter(|(i, _)| filtered_set.contains(i)) + .filter_map(|&i| app.tasks.get(i).map(|task| (i, task))) .map(|(i, task)| { let (symbol, style) = match &task.status { TaskStatus::Pending => (" ", Style::default().fg(Color::DarkGray)), diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 46ccea1..51a13b1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ pub mod path; +pub mod process; pub mod shell; pub mod sudo; diff --git a/src/utils/path.rs b/src/utils/path.rs index 3afdb28..7023b23 100644 --- a/src/utils/path.rs +++ b/src/utils/path.rs @@ -1,5 +1,9 @@ use std::path::{Path, PathBuf}; +use walkdir::{DirEntry, WalkDir}; + +use crate::error::Result; + /// Expand `~` to the user's home directory, `$VAR` to environment variables, /// and resolve relative paths against the base directory. pub fn expand_path(path: &str, base_dir: Option<&Path>) -> PathBuf { @@ -76,6 +80,28 @@ fn expand_env_vars(input: &str) -> String { result } +/// Walk `src` and invoke `f` for each entry that isn't filtered out by +/// `ignore_list`. The closure receives the raw `DirEntry` plus the +/// precomputed destination path (`target` joined with the entry's +/// `src`-relative suffix). +/// +/// `strip_prefix` is guaranteed to succeed because every WalkDir entry is +/// rooted at `src`. +pub fn walk_relative(src: &Path, target: &Path, ignore_list: &[String], mut f: F) -> Result<()> +where + F: FnMut(&DirEntry, &Path) -> Result<()>, +{ + for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) { + let relative = entry.path().strip_prefix(src).unwrap_or(entry.path()); + if should_ignore(relative, ignore_list) { + continue; + } + let dest = target.join(relative); + f(&entry, &dest)?; + } + Ok(()) +} + /// Check if a path should be ignored based on the ignore list. pub fn should_ignore(path: &Path, ignore_list: &[String]) -> bool { let path_str = path.to_string_lossy(); diff --git a/src/utils/process.rs b/src/utils/process.rs new file mode 100644 index 0000000..7f26b45 --- /dev/null +++ b/src/utils/process.rs @@ -0,0 +1,56 @@ +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Child; + +use crate::engine::context::CommandContext; + +/// Whether to tag stderr lines when forwarding them to the log. +#[derive(Copy, Clone)] +pub enum StderrLabel { + /// Prefix stderr lines with `[stderr]` so the UI can style them. + Prefixed, + /// Forward stderr unchanged (useful for tools like `git` that emit + /// progress on stderr). + Plain, +} + +/// Stream a child process's stdout and stderr to the context's event +/// channel, wait for the child to exit, and return its exit status. +pub async fn stream_and_wait( + mut child: Child, + ctx: &CommandContext, + stderr_label: StderrLabel, +) -> std::io::Result { + let stdout_handle = child.stdout.take().map(|stdout| { + let ctx = ctx.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + ctx.log(line); + } + }) + }); + + let stderr_handle = child.stderr.take().map(|stderr| { + let ctx = ctx.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + match stderr_label { + StderrLabel::Prefixed => ctx.log(format!("[stderr] {line}")), + StderrLabel::Plain => ctx.log(line), + } + } + }) + }); + + let status = child.wait().await?; + + if let Some(h) = stdout_handle { + let _ = h.await; + } + if let Some(h) = stderr_handle { + let _ = h.await; + } + + Ok(status) +} diff --git a/src/utils/shell.rs b/src/utils/shell.rs index cd05248..e8f83ce 100644 --- a/src/utils/shell.rs +++ b/src/utils/shell.rs @@ -34,15 +34,12 @@ pub fn shell_profile(shell: &Shell) -> Option { /// Check that an environment variable key is a valid identifier. pub fn validate_env_key(key: &str) -> bool { - if key.is_empty() { - return false; - } let mut chars = key.chars(); - let first = chars.next().unwrap(); - if !first.is_ascii_alphabetic() && first != '_' { + let Some(first) = chars.next() else { return false; - } - chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + }; + (first.is_ascii_alphabetic() || first == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } /// Escape a value for use inside a bash/zsh single-quoted string. @@ -91,7 +88,7 @@ pub fn build_shell_command( let val = if value.starts_with('~') { crate::utils::path::expand_path(value, None) .to_string_lossy() - .to_string() + .into_owned() } else { value.clone() };