nestrs-cli-rs 0.1.0

Rust port of the Nest CLI for the nestrs organization.
Documentation
//! Pure plan builder for upstream `nest-cli/actions/build.action.ts`.

use std::path::PathBuf;

use crate::actions::abstract_action::AbstractAction;
use crate::actions::{ActionInvocation, ActionKind, ActionSpec, action_spec};
use crate::commands::{Input, InputValue};
use crate::compiler::{
    BuildCommand, BuildPlan, BuildPlanRequest, BuilderVariant, CompilerCommandOptions,
    create_build_plan,
};
use crate::configuration::{CompilerOptions, Configuration, ProjectConfiguration};
use crate::{CliError, Result};

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

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

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

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

    pub fn create_plan(&self, request: BuildActionPlanRequest) -> Result<BuildActionPlan> {
        create_build_action_plan(request)
    }
}

impl AbstractAction for BuildAction {
    fn kind(&self) -> ActionKind {
        ActionKind::Build
    }
}

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

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BuildActionPlan {
    pub config_file_name: Option<String>,
    pub watch_mode: bool,
    pub watch_assets_mode: bool,
    pub app_names: Vec<Option<String>>,
    pub project_plans: Vec<ProjectBuildActionPlan>,
    pub type_check_warnings: Vec<TypeCheckWarning>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectBuildActionPlan {
    pub app_name: Option<String>,
    pub project_root: Option<PathBuf>,
    pub build_plan: BuildPlan,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TypeCheckWarning {
    pub app_name: Option<String>,
    pub builder: BuilderVariant,
    pub message: String,
}

pub fn create_build_action_plan(request: BuildActionPlanRequest) -> Result<BuildActionPlan> {
    let config_file_name = string_option(&request.command_options, "config");
    let watch_mode = bool_option(&request.command_options, "watch");
    let watch_assets_mode = bool_option(&request.command_options, "watchAssets");
    let build_all = bool_option(&request.command_options, "all");
    let compiler_command_options = compiler_command_options(&request.command_options)?;
    let app_names = resolve_app_names(&request.configuration, &request.command_inputs, build_all);

    let mut project_plans = Vec::with_capacity(app_names.len());
    let mut type_check_warnings = Vec::new();

    for app_name in &app_names {
        let project = project_configuration(&request.configuration, app_name.as_deref());
        let project_root = project.root.as_ref().map(PathBuf::from);
        let compiler_options = compiler_options(&request.configuration, app_name.as_deref());
        let command = BuildCommand {
            apps: app_name.iter().cloned().collect(),
            options: compiler_command_options.clone(),
        };

        let build_plan = create_build_plan(BuildPlanRequest {
            cwd: request.cwd.clone(),
            command,
            project,
            compiler_options,
            ts_build_info_file: request.ts_build_info_file.clone(),
        });

        if build_plan.inputs.type_check && build_plan.inputs.builder != BuilderVariant::Swc {
            type_check_warnings.push(TypeCheckWarning {
                app_name: app_name.clone(),
                builder: build_plan.inputs.builder,
                message: "\"typeCheck\" will not have any effect when \"builder\" is not \"swc\"."
                    .to_string(),
            });
        }

        project_plans.push(ProjectBuildActionPlan {
            app_name: app_name.clone(),
            project_root,
            build_plan,
        });
    }

    Ok(BuildActionPlan {
        config_file_name,
        watch_mode,
        watch_assets_mode,
        app_names,
        project_plans,
        type_check_warnings,
    })
}

pub(crate) fn compiler_command_options(options: &[Input]) -> Result<CompilerCommandOptions> {
    Ok(CompilerCommandOptions {
        path: string_option(options, "path"),
        webpack: bool_option_value(options, "webpack"),
        webpack_path: string_option(options, "webpackPath"),
        builder: builder_option(options)?,
        watch: bool_option_value(options, "watch"),
        watch_assets: bool_option_value(options, "watchAssets"),
        type_check: bool_option_value(options, "typeCheck"),
        preserve_watch_output: bool_option_value(options, "preserveWatchOutput"),
    })
}

pub(crate) fn resolve_app_names(
    configuration: &Configuration,
    command_inputs: &[Input],
    build_all: bool,
) -> Vec<Option<String>> {
    let mut app_names = if build_all {
        configuration
            .projects
            .keys()
            .cloned()
            .map(Some)
            .collect::<Vec<_>>()
    } else {
        command_inputs
            .iter()
            .filter(|input| input.name == "app")
            .map(|input| string_input_value(input.value.as_ref()))
            .collect::<Vec<_>>()
    };

    if app_names.is_empty() {
        app_names.push(None);
    }

    app_names
}

pub(crate) fn project_configuration(
    configuration: &Configuration,
    app_name: Option<&str>,
) -> ProjectConfiguration {
    app_name
        .and_then(|name| configuration.projects.get(name))
        .cloned()
        .unwrap_or_else(|| ProjectConfiguration {
            entry_file: Some(configuration.entry_file.clone()),
            exec: Some(configuration.exec.clone()),
            source_root: Some(configuration.source_root.clone()),
            compiler_options: Some(configuration.compiler_options.clone()),
            ..ProjectConfiguration::default()
        })
}

pub(crate) fn compiler_options(
    configuration: &Configuration,
    app_name: Option<&str>,
) -> CompilerOptions {
    app_name
        .and_then(|name| configuration.projects.get(name))
        .and_then(|project| project.compiler_options.clone())
        .unwrap_or_else(|| configuration.compiler_options.clone())
}

pub(crate) fn bool_option(options: &[Input], name: &str) -> bool {
    matches!(
        options
            .iter()
            .find(|option| option.name == name)
            .and_then(|option| option.value.as_ref()),
        Some(InputValue::Bool(true))
    )
}

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

pub(crate) fn string_option(options: &[Input], name: &str) -> Option<String> {
    options
        .iter()
        .find(|option| option.name == name)
        .and_then(|option| string_input_value(option.value.as_ref()))
}

pub(crate) fn string_list_option(options: &[Input], name: &str) -> Vec<String> {
    options
        .iter()
        .find(|option| option.name == name)
        .and_then(|option| match option.value.as_ref() {
            Some(InputValue::StringList(values)) => Some(values.clone()),
            _ => None,
        })
        .unwrap_or_default()
}

fn builder_option(options: &[Input]) -> Result<Option<BuilderVariant>> {
    string_option(options, "builder")
        .map(|builder| match builder.as_str() {
            "cargo" => Ok(BuilderVariant::Cargo),
            "tsc" => Ok(BuilderVariant::Tsc),
            "swc" => Ok(BuilderVariant::Swc),
            "webpack" => Ok(BuilderVariant::Webpack),
            _ => Err(CliError::UnsupportedCommand(format!(
                "Invalid builder option: {builder}. Available builder: cargo"
            ))),
        })
        .transpose()
}

fn string_input_value(value: Option<&InputValue>) -> Option<String> {
    match value {
        Some(InputValue::String(value)) => Some(value.clone()),
        _ => None,
    }
}