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/generate.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::{Configuration, DEFAULT_COLLECTION, GenerateSpec};
use crate::schematics::{CollectionFactory, SchematicOption};

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

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

    pub fn spec(&self) -> &'static ActionSpec {
        action_spec(ActionKind::Generate).expect("generate 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],
        configuration: &Configuration,
        options: GenerateActionPlanOptions,
    ) -> Result<GenerateActionPlan, String> {
        build_generate_action_plan(inputs, configuration, options)
    }
}

impl AbstractAction for GenerateAction {
    fn kind(&self) -> ActionKind {
        ActionKind::Generate
    }
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GenerateActionPlanOptions {
    pub selected_project: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GenerateActionPlan {
    pub collection: String,
    pub schematic: String,
    pub schematic_options: Vec<SchematicOption>,
    pub schematic_command: String,
    pub source_root: PathBuf,
    pub spec: bool,
    pub flat: bool,
    pub spec_file_suffix: String,
    pub project_selection: Option<ProjectSelectionPlan>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectSelectionPlan {
    pub default_project_name: String,
    pub projects: Vec<String>,
}

pub fn build_generate_action_plan(
    inputs: &[Input],
    configuration: &Configuration,
    options: GenerateActionPlanOptions,
) -> Result<GenerateActionPlan, String> {
    let collection = string_value(find_input(inputs, "collection"))
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| {
            if configuration.collection.is_empty() {
                DEFAULT_COLLECTION.to_string()
            } else {
                configuration.collection.clone()
            }
        });
    let schematic = string_value(find_input(inputs, "schematic"))
        .ok_or_else(|| "Unable to find a schematic for this configuration".to_string())?;
    let app_name = string_value(find_input(inputs, "project")).unwrap_or_default();
    let spec_value = bool_value(find_input(inputs, "spec")).unwrap_or(true);
    let flat_value = bool_value(find_input(inputs, "flat")).unwrap_or(false);
    let spec_file_suffix_value =
        string_value(find_input(inputs, "specFileSuffix")).unwrap_or_default();
    let spec_passed_as_input = find_input(inputs, "spec")
        .and_then(|input| input.options.as_ref())
        .map(|options| options.passed_as_input);

    let mut source_root = if app_name.is_empty() {
        configuration.source_root.clone()
    } else {
        project_source_root(configuration, &app_name)
            .unwrap_or_else(|| configuration.source_root.clone())
    };
    let mut generate_options = effective_generate_options(configuration, &app_name);
    let mut spec = should_generate_spec_with_options(
        &generate_options,
        &schematic,
        spec_value,
        spec_passed_as_input,
    );
    let mut flat = should_generate_flat_with_options(&generate_options, flat_value);
    let mut spec_file_suffix =
        get_spec_file_suffix_with_options(&generate_options, &spec_file_suffix_value);

    let project_selection = project_selection_plan(configuration, &schematic, &app_name);
    if let Some(selected_project_name) = options.selected_project.as_deref() {
        let project = selected_project_name.replace(DEFAULT_LABEL, "");
        if project != configuration.source_root {
            if let Some(selected_source_root) = project_source_root(configuration, &project) {
                source_root = selected_source_root;
            }
        }

        if project_selection
            .as_ref()
            .is_some_and(|selection| selected_project_name != selection.default_project_name)
        {
            generate_options = effective_generate_options(configuration, selected_project_name);
            spec = should_generate_spec_with_options(
                &generate_options,
                &schematic,
                spec_value,
                spec_passed_as_input,
            );
            flat = should_generate_flat_with_options(&generate_options, flat_value);
            spec_file_suffix =
                get_spec_file_suffix_with_options(&generate_options, &spec_file_suffix_value);
        }
    }

    if let Some(base_dir) = generate_options
        .base_dir
        .as_deref()
        .filter(|value| !value.is_empty())
    {
        source_root = join_path_string(&source_root, base_dir);
    }

    let mut schematic_options = map_schematic_options(inputs)?;
    schematic_options.push(SchematicOption::new(
        "language",
        configuration.language.clone(),
    ));
    schematic_options.push(SchematicOption::new("sourceRoot", source_root.clone()));
    schematic_options.push(SchematicOption::new("spec", spec));
    schematic_options.push(SchematicOption::new("flat", flat));
    schematic_options.push(SchematicOption::new(
        "specFileSuffix",
        spec_file_suffix.clone(),
    ));

    let schematic_command = CollectionFactory::create(collection.clone())
        .execute_command(&schematic, &schematic_options)?;

    Ok(GenerateActionPlan {
        collection,
        schematic,
        schematic_options,
        schematic_command,
        source_root: PathBuf::from(source_root),
        spec,
        flat,
        spec_file_suffix,
        project_selection,
    })
}

pub fn map_schematic_options(inputs: &[Input]) -> Result<Vec<SchematicOption>, String> {
    const EXCLUDED_INPUT_NAMES: &[&str] = &["schematic", "spec", "flat", "specFileSuffix"];

    inputs
        .iter()
        .filter(|input| !EXCLUDED_INPUT_NAMES.contains(&input.name.as_str()))
        .filter_map(input_to_schematic_option)
        .collect()
}

fn effective_generate_options(
    configuration: &Configuration,
    app_name: &str,
) -> crate::configuration::GenerateOptions {
    let mut options = configuration.generate_options.clone();
    if let Some(project_options) = configuration
        .projects
        .get(app_name)
        .and_then(|project| project.generate_options.clone())
    {
        if project_options.spec.is_some() {
            options.spec = project_options.spec;
        }
        if project_options.flat.is_some() {
            options.flat = project_options.flat;
        }
        if project_options.spec_file_suffix.is_some() {
            options.spec_file_suffix = project_options.spec_file_suffix;
        }
        if project_options.base_dir.is_some() {
            options.base_dir = project_options.base_dir;
        }
    }
    options
}

pub fn should_generate_spec(
    configuration: &Configuration,
    schematic: &str,
    app_name: &str,
    spec_value: bool,
    spec_passed_as_input: Option<bool>,
) -> bool {
    let generate_options = effective_generate_options(configuration, app_name);
    should_generate_spec_with_options(
        &generate_options,
        schematic,
        spec_value,
        spec_passed_as_input,
    )
}

fn should_generate_spec_with_options(
    generate_options: &crate::configuration::GenerateOptions,
    schematic: &str,
    spec_value: bool,
    spec_passed_as_input: Option<bool>,
) -> bool {
    if spec_passed_as_input.unwrap_or(true) {
        return spec_value;
    }

    match &generate_options.spec {
        Some(GenerateSpec::Bool(value)) => *value,
        Some(GenerateSpec::BySchematic(options)) => {
            options.get(schematic).copied().unwrap_or(spec_value)
        }
        None => spec_value,
    }
}

pub fn should_generate_flat(configuration: &Configuration, flat_value: bool) -> bool {
    should_generate_flat_with_options(&configuration.generate_options, flat_value)
}

fn should_generate_flat_with_options(
    generate_options: &crate::configuration::GenerateOptions,
    flat_value: bool,
) -> bool {
    if flat_value {
        return true;
    }

    generate_options.flat.unwrap_or(flat_value)
}

pub fn get_spec_file_suffix(configuration: &Configuration, spec_file_suffix_value: &str) -> String {
    get_spec_file_suffix_with_options(&configuration.generate_options, spec_file_suffix_value)
}

fn get_spec_file_suffix_with_options(
    generate_options: &crate::configuration::GenerateOptions,
    spec_file_suffix_value: &str,
) -> String {
    if !spec_file_suffix_value.is_empty() {
        return spec_file_suffix_value.to_string();
    }

    generate_options
        .spec_file_suffix
        .clone()
        .unwrap_or_else(|| "spec".to_string())
}

pub fn project_selection_plan(
    configuration: &Configuration,
    schematic: &str,
    app_name: &str,
) -> Option<ProjectSelectionPlan> {
    if !should_ask_for_project(schematic, configuration, app_name) {
        return None;
    }

    let default_project_name = default_project_name(configuration);
    let projects = move_default_project_to_start(configuration, &default_project_name);
    Some(ProjectSelectionPlan {
        default_project_name,
        projects,
    })
}

pub fn should_ask_for_project(
    schematic: &str,
    configuration: &Configuration,
    app_name: &str,
) -> bool {
    !matches!(schematic, "app" | "sub-app" | "library" | "lib")
        && !configuration.projects.is_empty()
        && app_name.is_empty()
}

fn default_project_name(configuration: &Configuration) -> String {
    configuration
        .projects
        .iter()
        .find(|(_, project)| {
            project
                .source_root
                .as_deref()
                .is_some_and(|source_root| source_root == configuration.source_root)
        })
        .map(|(name, _)| format!("{name}{DEFAULT_LABEL}"))
        .unwrap_or_else(|| format!("{}{}", configuration.source_root, DEFAULT_LABEL))
}

fn move_default_project_to_start(
    configuration: &Configuration,
    default_project_name: &str,
) -> Vec<String> {
    let default_project = default_project_name.replace(DEFAULT_LABEL, "");
    let mut projects = configuration.projects.keys().cloned().collect::<Vec<_>>();
    if configuration.source_root != "src" {
        projects.retain(|project| project != &default_project);
    }
    projects.insert(0, default_project_name.to_string());
    projects
}

fn project_source_root(configuration: &Configuration, app_name: &str) -> Option<String> {
    configuration
        .projects
        .get(app_name)
        .and_then(|project| project.source_root.clone())
}

fn join_path_string(left: &str, right: &str) -> String {
    Path::new(left).join(right).to_string_lossy().into_owned()
}

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,
    }
}

const DEFAULT_LABEL: &str = " [ Default ]";