nestrs-cli-rs 0.1.0

Rust port of the Nest CLI for the nestrs organization.
Documentation
//! Pure plan builder for Rust `nest start` execution.

use std::path::{Path, PathBuf};

use crate::Result;
use crate::actions::abstract_action::AbstractAction;
use crate::actions::{ActionInvocation, ActionKind, ActionSpec, action_spec};
use crate::build_action::{
    BuildActionPlan, BuildActionPlanRequest, create_build_action_plan, project_configuration,
    string_list_option, string_option,
};
use crate::commands::{Input, InputValue};
use crate::configuration::{Configuration, DEFAULT_ENTRY_FILE, DEFAULT_SOURCE_ROOT};
use crate::runners::RunnerCommand;

/// Typed wrapper for upstream `StartAction`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct StartAction;

impl StartAction {
    pub const fn new() -> Self {
        Self
    }

    pub fn spec(&self) -> &'static ActionSpec {
        action_spec(ActionKind::Start).expect("start action spec")
    }

    pub fn handle_invocation(
        &self,
        inputs: Vec<Input>,
        options: Vec<Input>,
        extra_flags: Vec<String>,
    ) -> ActionInvocation {
        <Self as AbstractAction>::handle(self, inputs, options, extra_flags)
    }

    pub fn create_plan(&self, request: StartActionPlanRequest) -> Result<StartActionPlan> {
        create_start_action_plan(request)
    }
}

impl AbstractAction for StartAction {
    fn kind(&self) -> ActionKind {
        ActionKind::Start
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StartActionPlanRequest {
    pub cwd: PathBuf,
    pub configuration: Configuration,
    pub command_inputs: Vec<Input>,
    pub command_options: Vec<Input>,
    pub extra_flags: Vec<String>,
    pub ts_build_info_file: Option<PathBuf>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StartActionPlan {
    pub app_name: Option<String>,
    pub build_plan: BuildActionPlan,
    pub process_plan: StartProcessPlan,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StartProcessPlan {
    pub entry_file: String,
    pub source_root: String,
    pub debug_flag: Option<DebugFlag>,
    pub out_dir_name: PathBuf,
    pub binary_to_run: String,
    pub requested_exec: Option<String>,
    pub shell: bool,
    pub env_file: Vec<String>,
    pub enable_source_maps: bool,
    pub child_process_args: Vec<String>,
    pub manifest_path: Option<PathBuf>,
    pub source_root_output: PathBuf,
    pub fallback_output: PathBuf,
    pub source_root_command: RunnerCommand,
    pub fallback_command: RunnerCommand,
    pub restart: StartRestartPlan,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DebugFlag {
    Inspect,
    InspectAddress(String),
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StartRestartPlan {
    pub kill_previous_process_on_success: bool,
    pub forward_sigint: bool,
    pub forward_sigterm: bool,
    pub kill_process_on_parent_exit: bool,
}

pub fn create_start_action_plan(request: StartActionPlanRequest) -> Result<StartActionPlan> {
    let cwd = request.cwd.clone();
    let app_name = request
        .command_inputs
        .iter()
        .find(|input| input.name == "app")
        .and_then(|input| match input.value.as_ref() {
            Some(InputValue::String(value)) => Some(value.clone()),
            _ => None,
        });

    let project = project_configuration(&request.configuration, app_name.as_deref());

    let build_plan = create_build_action_plan(BuildActionPlanRequest {
        cwd: request.cwd,
        configuration: request.configuration,
        command_inputs: request.command_inputs,
        command_options: request.command_options.clone(),
        ts_build_info_file: request.ts_build_info_file,
    })?;

    let project_build_plan = build_plan
        .project_plans
        .iter()
        .find(|plan| plan.app_name == app_name)
        .or_else(|| build_plan.project_plans.first());
    let out_dir_name = project_build_plan
        .map(|plan| plan.build_plan.inputs.output_dir.clone())
        .unwrap_or_else(|| PathBuf::from("dist"));

    let entry_file = string_option(&request.command_options, "entryFile")
        .or(project.entry_file)
        .unwrap_or_else(|| DEFAULT_ENTRY_FILE.to_string());
    let source_root = string_option(&request.command_options, "sourceRoot")
        .or(project.source_root)
        .unwrap_or_else(|| DEFAULT_SOURCE_ROOT.to_string());
    let requested_exec = string_option(&request.command_options, "exec");
    let binary_to_run = "cargo".to_string();
    let debug_flag = debug_flag(&request.command_options);
    let shell = bool_option_default(&request.command_options, "shell", true);
    let env_file = string_list_option(&request.command_options, "envFile");
    let child_process_args = request.extra_flags;
    let manifest_path = project_manifest_path(
        project_build_plan.and_then(|plan| plan.project_root.as_deref()),
        &source_root,
    );

    let process_plan = create_start_process_plan(
        entry_file,
        source_root,
        debug_flag,
        out_dir_name,
        binary_to_run,
        requested_exec,
        shell,
        env_file,
        child_process_args,
        manifest_path,
        cwd,
    );

    Ok(StartActionPlan {
        app_name,
        build_plan,
        process_plan,
    })
}

pub fn create_start_process_plan(
    entry_file: String,
    source_root: String,
    debug_flag: Option<DebugFlag>,
    out_dir_name: PathBuf,
    binary_to_run: String,
    requested_exec: Option<String>,
    shell: bool,
    env_file: Vec<String>,
    child_process_args: Vec<String>,
    manifest_path: Option<PathBuf>,
    cwd: PathBuf,
) -> StartProcessPlan {
    let source_root_output = out_dir_name
        .join(path_from_slash_separated(&source_root))
        .join(&entry_file);
    let fallback_output = out_dir_name.join(&entry_file);
    let command = cargo_run_command(manifest_path.as_deref(), &child_process_args);
    let runner_command = RunnerCommand {
        binary: "cargo".to_string(),
        prefix_args: Vec::new(),
        command,
        collect: false,
        cwd: Some(cwd),
        shell: true,
        env: Vec::new(),
    };

    StartProcessPlan {
        entry_file,
        source_root,
        debug_flag,
        out_dir_name,
        binary_to_run,
        requested_exec,
        shell,
        env_file,
        enable_source_maps: false,
        child_process_args,
        manifest_path,
        source_root_output,
        fallback_output,
        source_root_command: runner_command.clone(),
        fallback_command: runner_command,
        restart: StartRestartPlan {
            kill_previous_process_on_success: true,
            forward_sigint: true,
            forward_sigterm: true,
            kill_process_on_parent_exit: true,
        },
    }
}

fn debug_flag(options: &[Input]) -> Option<DebugFlag> {
    options
        .iter()
        .find(|option| option.name == "debug")
        .and_then(|option| match option.value.as_ref() {
            Some(InputValue::Bool(true)) => Some(DebugFlag::Inspect),
            Some(InputValue::String(value)) if value.is_empty() => Some(DebugFlag::Inspect),
            Some(InputValue::String(value)) => Some(DebugFlag::InspectAddress(value.clone())),
            _ => None,
        })
}

fn bool_option_default(options: &[Input], name: &str, default: bool) -> bool {
    options
        .iter()
        .find(|option| option.name == name)
        .and_then(|option| match option.value.as_ref() {
            Some(InputValue::Bool(value)) => Some(*value),
            _ => None,
        })
        .unwrap_or(default)
}

fn path_from_slash_separated(path: &str) -> PathBuf {
    path.split(['/', '\\'])
        .filter(|part| !part.is_empty())
        .collect()
}

fn project_manifest_path(project_root: Option<&Path>, source_root: &str) -> Option<PathBuf> {
    if let Some(project_root) = project_root {
        return Some(project_root.join("Cargo.toml"));
    }

    let source_root = path_from_slash_separated(source_root);
    source_root
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .map(|parent| parent.join("Cargo.toml"))
}

fn cargo_run_command(manifest_path: Option<&Path>, child_process_args: &[String]) -> String {
    let mut parts = vec!["run".to_string()];

    if let Some(manifest_path) = manifest_path {
        parts.push("--manifest-path".to_string());
        parts.push(quote_command_arg(&manifest_path.display().to_string()));
    }

    if !child_process_args.is_empty() {
        parts.push("--".to_string());
        parts.extend(child_process_args.iter().map(|arg| quote_command_arg(arg)));
    }

    parts.join(" ")
}

fn quote_command_arg(value: &str) -> String {
    if value.is_empty() || value.chars().any(char::is_whitespace) || value.contains('"') {
        format!("\"{}\"", value.replace('"', "\\\""))
    } else {
        value.to_string()
    }
}