vstask 0.3.0

Run VS Code task from the terminal
Documentation
use serde::Deserialize;
use std::{collections::HashMap, env, path::Path, process::Command};
use tracing::info;

// Command options
#[derive(Debug, Deserialize, Clone, Default)]
struct CommandOptions {
    // current working directory
    cwd: Option<String>,
    // environment variables passed to the task
    env: Option<HashMap<String, String>>,
}

// --- Platform-specific configuration ---
#[derive(Debug, Deserialize, Clone)]
struct PlatformConfig {
    command: Option<String>,
    args: Option<Vec<String>>,
    options: Option<CommandOptions>,
}

// --- Task Definition dervied from https://code.visualstudio.com/docs/reference/tasks-appendix ---
/// Structure to deserialize a single task from JSON
#[derive(Debug, Deserialize)]
pub struct Task {
    pub label: String,
    #[serde(rename = "type")] // Map "type" field to avoid Rust keyword conflict
    task_type: String,
    command: Option<String>,
    #[serde(rename = "isBackground")]
    is_background: Option<bool>,
    options: Option<CommandOptions>,
    args: Option<Vec<String>>,
    // Platform-specific configurations
    #[allow(dead_code)]
    windows: Option<PlatformConfig>,
    #[allow(dead_code)]
    linux: Option<PlatformConfig>,
    #[allow(dead_code)]
    osx: Option<PlatformConfig>,
}

// --- Tasks Container ---
/// Structure to deserialize the entire tasks.json file
#[derive(Debug, Deserialize)]
pub struct TasksFile {
    pub version: String,
    pub tasks: Vec<Task>,
}

pub fn list_tasks(tasks: &[Task], json_output: bool) {
    if tasks.is_empty() {
        if json_output {
            println!("[]");
        } else {
            println!("No tasks found in tasks.json");
        }
        return;
    }

    if json_output {
        let labels: Vec<&str> = tasks.iter().map(|t| t.label.as_str()).collect();
        println!("{}", serde_json::to_string_pretty(&labels).unwrap());
    } else {
        for task in tasks {
            println!("{}", task.label);
        }
    }
}

pub fn execute_task(task: &Task) -> Result<(), Box<dyn std::error::Error>> {
    info!("Executing task: '{}'", task.label);

    let is_background = task.is_background.unwrap_or(false);

    let workspace_root = env::current_dir()?;

    // Get platform-specific configuration
    let (mut command_str, args, options) = get_platform_config(task)?;

    // Apply variable substitution to command
    command_str = substitute_variables(&command_str, &workspace_root);

    // Apply variable substitution to args if present
    let args = args.map(|mut args_vec| {
        for arg in &mut args_vec {
            *arg = substitute_variables(arg, &workspace_root);
        }
        args_vec
    });

    let mut command = if task.task_type == "shell" {
        // For shell tasks, we need to invoke the shell
        #[cfg(target_os = "windows")]
        let cmd = {
            let mut c = Command::new("cmd");
            c.args(["/C", &command_str]);
            c
        };

        #[cfg(not(target_os = "windows"))]
        let cmd = {
            // Use the user's default shell from environment, fallback to zsh
            let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
            let mut c = Command::new(shell);
            c.args(["-c", &command_str]);
            c
        };

        info!("  Shell command: {}", command_str);
        cmd
    } else {
        // For process tasks, command is the executable
        let mut cmd = Command::new(&command_str);

        // Add arguments if present
        if let Some(args) = &args {
            cmd.args(args);
        }

        info!(
            "  Command: {} {:?}",
            command_str,
            args.as_deref().unwrap_or(&[])
        );
        cmd
    };

    // Apply options if present
    if let Some(options) = &options {
        info!("Found options: {:?}", options);

        // Set current working directory with variable substitution
        if let Some(cwd) = &options.cwd {
            info!("Found cwd: {:?}", cwd);
            let expanded_cwd = substitute_variables(cwd, &workspace_root);
            info!("  Setting working directory: {}", expanded_cwd);
            command.current_dir(&expanded_cwd);
        }

        // Set environment variables with variable substitution
        if let Some(env_vars) = &options.env {
            info!("Found {} environment variables", env_vars.len());
            for (key, value) in env_vars {
                let expanded_value = substitute_variables(value, &workspace_root);
                info!("  Setting env: {}={}", key, expanded_value);
                command.env(key, &expanded_value);
            }
        }
    } else {
        info!("No options found for this task");
    }

    if is_background {
        info!("Starting background task '{}'...", task.label);
        command.spawn().map_err(|e| {
            format!(
                "Failed to spawn background command '{}': {}",
                command_str, e
            )
        })?;
        info!("Background task '{}' started.", task.label);
    } else {
        // Execute the command and capture output
        let output = command
            .output()
            .map_err(|e| format!("Failed to execute command '{}': {}", command_str, e))?;

        // Print stdout and stderr
        if !output.stdout.is_empty() {
            print!("{}", String::from_utf8_lossy(&output.stdout));
        }
        if !output.stderr.is_empty() {
            eprint!("{}", String::from_utf8_lossy(&output.stderr));
        }

        if output.status.success() {
            info!("Task '{}' completed successfully.", task.label);
        } else {
            return Err(format!(
                "Task '{}' failed with exit code: {:?}",
                task.label,
                output.status.code()
            )
            .into());
        }
    }

    Ok(())
}

type PlatformConfigResult =
    Result<(String, Option<Vec<String>>, Option<CommandOptions>), Box<dyn std::error::Error>>;

fn get_platform_config(task: &Task) -> PlatformConfigResult {
    // Determine current platform
    #[cfg(target_os = "windows")]
    let platform_config = &task.windows;

    #[cfg(target_os = "linux")]
    let platform_config = &task.linux;

    #[cfg(target_os = "macos")]
    let platform_config = &task.osx;

    // Use platform-specific config if available, otherwise fall back to default
    if let Some(config) = platform_config {
        let command = config
            .command
            .as_ref()
            .or(task.command.as_ref())
            .ok_or("No command specified for task")?
            .clone();

        let args = config.args.as_ref().or(task.args.as_ref()).cloned();

        // Merge options: start with top-level options, then merge platform-specific
        let mut merged_options = task.options.clone().unwrap_or_default();
        if let Some(platform_options) = &config.options {
            // Merge cwd (platform-specific takes precedence)
            if platform_options.cwd.is_some() {
                merged_options.cwd = platform_options.cwd.clone();
            }
            // Merge env variables (platform-specific extends/overrides top-level)
            if let Some(platform_env) = &platform_options.env {
                let mut combined_env = merged_options.env.unwrap_or_default();
                combined_env.extend(platform_env.clone());
                merged_options.env = Some(combined_env);
            }
        }

        Ok((command, args, Some(merged_options)))
    } else {
        // Fall back to default task configuration
        let command = task
            .command
            .as_ref()
            .ok_or("No command specified for task")?
            .clone();

        Ok((command, task.args.clone(), task.options.clone()))
    }
}

fn substitute_variables(input: &str, workspace_root: &Path) -> String {
    let mut result = input.to_string();

    // Replace ${workspaceFolder} with the current workspace directory
    result = result.replace(
        "${workspaceFolder}",
        workspace_root.to_string_lossy().as_ref(),
    );

    // Replace environment variables like ${env:VAR_NAME}
    let env_regex = regex::Regex::new(r"\$\{env:([^}]+)\}").unwrap();
    result = env_regex
        .replace_all(&result, |caps: &regex::Captures| {
            let var_name = &caps[1];
            env::var(var_name).unwrap_or_else(|_| {
                tracing::warn!("Environment variable '{}' not found", var_name);
                format!(
                    "${{{}}}",
                    caps[0]
                        .strip_prefix("${")
                        .unwrap()
                        .strip_suffix("}")
                        .unwrap()
                )
            })
        })
        .to_string();

    result
}