nestrs-cli-rs 0.1.0

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

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

use crate::actions::abstract_action::AbstractAction;
use crate::actions::{ActionInvocation, ActionKind, ActionSpec, action_spec};
use crate::commands::{Input, InputValue};
use crate::configuration::DEFAULT_COLLECTION;
use crate::package_managers::{PackageManager, PackageManagerClient};
use crate::runners::{Runner, RunnerCommand, RunnerFactory, RunnerKind};
use crate::schematics::{CollectionFactory, SchematicOption};
use crate::utils::normalize_to_kebab_or_snake_case;

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

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

    pub fn spec(&self) -> &'static ActionSpec {
        action_spec(ActionKind::New).expect("new 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 build_plan(
        &self,
        inputs: &[Input],
        options: &[Input],
        current_dir: impl AsRef<Path>,
    ) -> Result<NewActionPlan, String> {
        build_new_action_plan(inputs, options, current_dir)
    }
}

impl AbstractAction for NewAction {
    fn kind(&self) -> ActionKind {
        ActionKind::New
    }
}

pub const DEFAULT_NEW_APPLICATION_NAME: &str = "nest-app";

pub const DEFAULT_GIT_IGNORE: &str = r#"# compiled output
/dist
/node_modules
/build

# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# OS
.DS_Store

# Tests
/coverage
/.nyc_output

# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace

# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local

# temp directory
.temp
.tmp

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
"#;

/// Rust equivalent of upstream `retrieveCols`.
pub fn retrieve_cols() -> Option<usize> {
    std::env::var("COLUMNS")
        .ok()
        .and_then(|value| value.parse::<usize>().ok())
        .filter(|columns| *columns > 0)
        .or(Some(80))
}

/// Rust equivalent of upstream `exit`.
pub fn exit() -> ! {
    std::process::exit(1)
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NewActionPlan {
    pub collection: String,
    pub schematic: String,
    pub schematic_options: Vec<SchematicOption>,
    pub schematic_command: String,
    pub project_directory: PathBuf,
    pub package_manager: Option<PackageManager>,
    pub install_command: Option<RunnerCommand>,
    pub initialize_git_command: Option<RunnerCommand>,
    pub create_git_ignore: bool,
    pub git_ignore_content: Option<&'static str>,
    pub print_collective: bool,
    pub dry_run: bool,
    pub missing_information: Vec<NewActionMissingInformation>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NewActionMissingInformation {
    Name,
    PackageManager,
}

pub fn build_new_action_plan(
    inputs: &[Input],
    options: &[Input],
    current_dir: impl AsRef<Path>,
) -> Result<NewActionPlan, String> {
    let name = string_value(find_input(inputs, "name"))
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| DEFAULT_NEW_APPLICATION_NAME.to_string());
    let dry_run = bool_value(find_input(options, "dry-run")).unwrap_or(false);
    let skip_install = bool_value(find_input(options, "skip-install")).unwrap_or(false);
    let skip_git = bool_value(find_input(options, "skip-git")).unwrap_or(false);
    let project_directory = get_project_directory(&name, find_input(options, "directory"));
    let collection = string_value(find_input(options, "collection"))
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| DEFAULT_COLLECTION.to_string());

    let mut missing_information = Vec::new();
    if string_value(find_input(inputs, "name")).is_none() {
        missing_information.push(NewActionMissingInformation::Name);
    }
    if string_value(find_input(options, "packageManager")).is_none() {
        missing_information.push(NewActionMissingInformation::PackageManager);
    }

    let schematic_options = map_schematic_options(inputs, options)?;
    let schematic_command = CollectionFactory::create(collection.clone())
        .execute_command("application", &schematic_options)?;

    let package_manager = string_value(find_input(options, "packageManager"))
        .filter(|value| !value.is_empty())
        .map(|value| {
            value
                .parse::<PackageManager>()
                .map_err(|error| error.to_string())
        })
        .transpose()?;
    let install_command = if skip_install || dry_run {
        None
    } else {
        package_manager.map(|manager| {
            PackageManagerClient::new(manager).install_command(project_directory.clone())
        })
    };

    let project_path = current_dir.as_ref().join(&project_directory);
    let initialize_git_command = if dry_run || skip_git {
        None
    } else {
        Some(RunnerFactory::create(RunnerKind::Git).describe("init", true, Some(project_path)))
    };

    Ok(NewActionPlan {
        collection,
        schematic: "application".to_string(),
        schematic_options,
        schematic_command,
        project_directory,
        package_manager,
        install_command,
        initialize_git_command,
        create_git_ignore: !dry_run && !skip_git,
        git_ignore_content: (!dry_run && !skip_git).then_some(DEFAULT_GIT_IGNORE),
        print_collective: !dry_run,
        dry_run,
        missing_information,
    })
}

pub fn get_project_directory(application_name: &str, directory_option: Option<&Input>) -> PathBuf {
    string_value(directory_option)
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from(normalize_to_kebab_or_snake_case(application_name)))
}

pub fn map_schematic_options(
    inputs: &[Input],
    options: &[Input],
) -> Result<Vec<SchematicOption>, String> {
    inputs
        .iter()
        .chain(options.iter())
        .filter(|input| input.name != "skip-install")
        .filter_map(input_to_schematic_option)
        .collect()
}

fn input_to_schematic_option(input: &Input) -> Option<Result<SchematicOption, String>> {
    let value = input.value.as_ref()?;
    Some(match value {
        InputValue::Bool(value) => Ok(SchematicOption::new(&input.name, *value)),
        InputValue::String(value) => Ok(SchematicOption::new(&input.name, value.clone())),
        InputValue::StringList(_) => Err(format!(
            "Input `{}` cannot be mapped to a schematic option because arrays are not supported",
            input.name
        )),
    })
}

fn find_input<'a>(inputs: &'a [Input], name: &str) -> Option<&'a Input> {
    inputs.iter().find(|input| input.name == name)
}

fn string_value(input: Option<&Input>) -> Option<String> {
    match input?.value.as_ref()? {
        InputValue::String(value) => Some(value.clone()),
        _ => None,
    }
}

fn bool_value(input: Option<&Input>) -> Option<bool> {
    match input?.value.as_ref()? {
        InputValue::Bool(value) => Some(*value),
        _ => None,
    }
}